原文:zh.annas-archive.org/md5/8f93267a61af3c28aa765d9c5f3f38e8

译者:飞龙

协议:CC BY-NC-SA 4.0

第六章:测试与提示工程

本章涵盖

  • 在第三章中,我们首先开始看到测试 Copilot 生成的代码的重要性。对于任何编写软件的人来说,测试是一项基本技能,因为它能让你对代码的正常运行有信心。在本章中,我们将学习如何彻底测试我们的代码,以及如何通过修改我们的提示来帮助 Copilot 修复不工作的代码。

  • 使用封闭盒与开放盒测试

  • 通过修改提示来解决 Copilot 的错误

  • 通过测试 Copilot 生成的代码的示例

理解测试 Copilot 代码的重要性

测试是一项你需要学会如何独立做好的基本技能,这样你才能检查代码是否正确运行。Copilot 可以生成测试,并且它在生成的测试质量上一直在改进,但我们鼓励你暂时不要使用 Copilot 来生成测试,因为你需要学会自己足够好地做这件事,以便能够验证 Copilot 生成的测试是否合理。这一点在接下来的几章中也将是真实的——问题分解、测试和调试都是在你请求 Copilot 的帮助之前必须学会的技能,因为你需要知道如何自己完成这些任务,才能知道 Copilot 是否在合理地做事。

6.1 为什么测试代码至关重要

回到第三章,我们提到你应该测试代码以确保它是正确的。不幸的是,根据我们的经验,初学者似乎对测试有抵触情绪!为什么?我们认为有几个因素在起作用。第一个是有一个被广泛记录的问题,被称为超级虫,即人类在刚开始学习编程时,认为计算机可以理解代码的意图并相应地做出反应[1]。因为他们写了代码,代码对他们来说是有意义的,所以他们很难想象代码可能不起作用。第二个问题是在第一个问题的基础上加剧的:如果你认为你的代码是正确的,测试只能给你带来坏消息。如果你不测试,你就无法发现代码是否错误。这就像那句古老的谚语,把头埋在沙子里。

软件工程师与新手程序员采取的方法完全不同。他们非常重视测试,因为代码中的错误可能会给他们的公司带来重大后果。没有人愿意成为导致公司损失大量收入、让黑客获取用户机密数据或导致自动驾驶汽车发生事故的代码编写者。考虑到错误的成本,假设代码是错误的直到证明其正确更有意义。只有经过广泛的测试后,我们才应该相信它正在正确运行。而且,公司不仅仅测试一次代码,他们还保留测试系统,以便每次有人更改代码时,不仅对更改的代码进行测试,而且对更改的代码可能影响的任何代码也进行测试(这被称为回归测试)。

公司对此非常重视,以至于他们经常在编写代码之前编写测试,这个过程被称为测试驱动开发(TDD)。这确保了每个人都同意代码应该或不应该做什么。我们认为你(作为读者)不需要采取这种方法来编写这本书中的程序,但我们在这里提到它,以传达测试的重要性。在编写代码之前考虑测试可以帮助你理解代码应该做什么,这将帮助你编写更好的提示。事实上,你可以在提示中直接包含测试案例!

最后,让我们记住关于 Copilot 我们所知道的事情:它会犯错误。我们不应该对我们从 Copilot 那里得到的任何代码的正确性做出任何假设。所有这些都意味着,在信任 Copilot 给出的任何代码之前,都应该对其进行测试。

6.2 封闭盒子和开箱测试

软件工程师通常有两种测试代码的方法。第一种被称为封闭盒测试,这种方法假设你对代码的工作方式一无所知。因此,这种测试涉及改变输入并观察输出。我们经常看到封闭盒测试应用于函数或整个程序。封闭盒测试的优势在于,你不需要查看代码来执行测试,因此可以简单地专注于期望的行为。

测试的第二种方法被称为开箱测试,在这种方法中,我们查看代码以确定错误可能发生的位置。开箱测试的优势在于,通过查看代码的特定结构,我们可能看到代码可能失败的地方,并可以针对该代码设计特定的额外测试。我们将使用封闭盒子和开箱子测试来制定测试案例,以增强我们的测试。封闭盒子和开箱子测试的简要概述见表 6.1。在本节中,让我们看看我们可能如何使用这些方法来测试一些函数。

表 6.1 封闭盒子和开箱测试简要概述
封闭盒测试 开箱测试
测试需要理解功能规范。测试需要同时理解功能规范和实现该功能的代码。
测试不需要理解代码的功能。测试应根据代码的编写方式定制。
测试人员不需要对所测试的代码有技术专长。测试人员需要足够理解代码,以确定哪些测试可能更重要。
通过改变输入并检查预期结果来测试函数 可以像封闭盒测试一样测试函数,但也可以在函数内部进行更细粒度的测试

6.2.1 封闭盒测试

让我们想象我们正在尝试测试一个函数,该函数接受一个单词(字符串)列表并返回最长的单词。为了更精确,函数签名将是

def longest_word(words):

预期输入是一个单词列表。预期输出是列表中最长的单词。如果多个单词字符数相同,则应返回该长度的第一个单词。

测试用例的缩写表示

在编写函数测试时,标准格式是编写函数名及其输入以及期望的结果。例如,调用

>>> longest_word(['a', 'bb', 'ccc'])
'ccc'

意味着如果我们用输入列表['a', 'bb', 'ccc']调用函数longest_word,那么函数返回的值应该是'ccc'

对于我们通常考虑编写测试用例的两个类别:

  • *常见用例—*这些情况包括一些标准输入,你可以想象函数会接收这些输入并得到相应的结果。

  • **边缘情况—这些情况是不常见但可能破坏代码的情况。这些输入可能更深入地测试函数的一些规则或包含意外的输入(例如,所有字符串都为空的列表)。

**回顾一下前一个例子中的longest_word函数签名,让我们考虑一些可能用来测试它的测试用例。在本章的后面部分,我们将看到如何实际运行这些测试用例以确定我们的代码是否正确工作。让我们从常见用例开始。我们可能希望包括一个只有几个单词的测试,其中一个单词比其他单词长:

>>> longest_word(['cat', 'dog', 'bird']) 
'bird'

这里有一个包含更多单词的测试,其中最长的单词出现在列表的其他地方:

>>> longest_word(['happy', 'birthday', 'my', 'cat']) 
'birthday'

最后,让我们有一个只有一个单词的测试:

>>> longest_word(['happy']) 
'happy'

如果程序适用于这些常见用途,我们的下一步将是考虑一些边缘情况。让我们考虑一些边缘情况。

假设我们想要检查函数是否按照我们的描述返回多个相同长度单词中的第一个单词。这个测试可能被认为是常见情况或边缘情况,这取决于你问的是谁:

>>> longest_word(['cat', 'dog', 'me']) 
'cat'

如果列表中的所有单词都没有字符怎么办?没有字符的字符串被称为空字符串,写作只是一个空的引号对。如果我们只有一串空字符串,那么最长的单词就是空字符串!因此,使用所有空字符串的测试应该只返回一个空字符串:

>>> longest_word(['', ''])
''

术语边缘情况来源于错误通常发生在执行的“边缘”,意味着第一个或最后一个元素。在许多循环中,错误可能发生在循环开始时(例如,忘记或错误处理列表中的第一个元素)或结束时(例如,忘记最后一个元素或超出列表的末尾并尝试访问一个不存在的元素)。特别是当代码可能需要处理许多元素的循环时,你将希望观察循环的开始和结束处的行为。

不正确的输入测试

另一类测试将测试函数在接收到不正确输入时的响应。在我们的书中我们不会过多地讨论这一点,因为我们假设你正在正确地调用自己的函数,但在实际代码中,这种测试可能是常见的。调用此函数的不正确输入的几个例子可能包括使用值None而不是实际的列表来给函数一个不存在的列表(例如,longest_word(None)),给函数一个空列表(例如,longest_word([])),给函数一个整数列表作为输入(例如,longest_word([1,2])),或者提供一个包含空格或超过单个单词的字符串列表(例如,longest_word(['hi there', 'my', 'friend']))。很难说函数在接收到不正确输入时应该做什么,程序员需要决定他们是否关心在更大的代码库中这一点,但在这本书中我们将忽略这一类测试,因为我们假设你会以函数设计的方式来调用自己的函数。

6.2.2 我们如何知道要使用哪些测试用例?

在第三章中,我们讨论了良好的测试涉及捕获不同类别的函数调用。找到这些类别的一种方法是通过使用参数类型并改变它们的值。

例如,如果函数接受一个字符串或列表作为参数,那么测试该字符串或列表为空、有一个元素和有多个元素的情况是有意义的。如果我们试图测试多个元素,我们可能会使用四个元素,例如。使用五个或六个元素或更多进行测试可能也没有意义,因为如果我们的代码可以处理四个元素,那么当我们增加到五个时,突然出错的可能性不大。有时,某些测试用例可能对给定的函数没有意义;例如,在列表中没有单词的情况下要求最长的单词是没有意义的,因此我们不会为我们的longest_word函数测试空列表。

作为另一个例子,如果一个函数接受两个数字作为参数,那么测试一个数字为零、两个数字都为零、一个数字为负、两个数字都为负以及两个数字都为正的情况可能是有意义的。

另一种寻找类别的方法是思考函数的具体任务。例如,对于我们的longest_word函数,它的任务应该是找到最长的单词,因此我们应该测试它在典型情况下是否真的做到了这一点。而且,如果存在多个最长的单词,它应该返回这些单词中的第一个,因此我们应该有一个测试案例,其中列表包含多个最长的单词。

寻找要测试的类别是一种科学与艺术的结合。我们在这里提供了一些经验法则,但什么是有用的测试案例往往取决于正在测试的具体功能。正如经常发生的那样,练习你的测试技能是提高你编写有用测试的能力的最佳方式,这些测试最终有助于你改进代码。

6.2.3 开放盒测试

开放盒测试和封闭盒测试之间的主要区别在于,开放盒测试会检查代码以查看是否有额外的测试案例类型需要检查。理论上,封闭盒测试可能足以完全测试函数,但开放盒测试往往能给你更多关于代码可能失败的地方的思路。假设我们要求 Copilot 编写我们的longest_word函数,并得到了以下列表中所示的代码。

列表 6.1:查找最长单词的函数(错误!)
def longest_word(words):
 ''' 
 words is a list of words

 return the word from the list with the most characters
 if multiple words are the longest, return the first 
 such word 
 '''
    longest = ""
    for i in range(0,len(words)):
        if len(words[i]) >= len(longest):     #1
            longest = words[i]
    return longest

#1 >=是错误的。它应该是>

在这个例子中,我们故意在代码中引入了一个错误,以帮助解释开放盒测试的作用。假设你在思考测试案例时,忘记测试当words列表中有两个单词都具有最多字符时会发生什么。通过阅读这段代码,你可能会注意到以下if语句:

   if len(words[i]) >= len(longest):
        longest = words[i]

在阅读if语句时,你可能会注意到它将在最近一个元素的长度大于或等于我们迄今为止看到的最长单词时更新单词列表中的最长单词。这是一个错误;它应该是>,而不是>=,但假设你不确定。这会促使你编写一个像我们之前描述的那样具有多个单词的测试案例:

>>> longest_word(['cat', 'dog', 'me']) 
'cat'

这个测试将因为列表 6.1 中的代码而失败,因为它会返回'dog'而不是正确的答案'cat'。测试失败是宝贵的情报,表明列表 6.1 中的代码是错误的。

正如我们所说的,开箱测试是有用的,因为它会导致遵循代码自身结构的测试用例。例如,如果我们的代码使用了循环,我们将在进行开箱测试时找到那个循环。列表 6.1 中的循环是正确的,但通过看到我们的代码中的循环,我们会提醒自己测试边界情况,以确保它正确地处理了第一个元素、最后一个元素以及空列表。总的来说,了解代码如何处理输入通常可以提供关于程序何时可能出现故障的见解。

6.3 如何测试你的代码

有许多好的方法可以测试你的代码,这些方法从你为了检查代码是否对你自己工作而进行的快速测试,到集成到公司回归测试套件中的测试。对于生产代码,Python 程序员通常使用比我们在本章将要展示的更强大、功能更全面的测试工具。其中最常见的是 pytest,这是一个在使用之前需要安装的模块。我们认为 pytest 超出了我们在这里介绍测试核心思想的需要。我们将专注于更轻量级的测试,以帮助你确信 Copilot 生成的代码能够正常工作。我们可以通过在 Python 提示符中进行测试或使用一个名为 doctest 的内置 Python 模块来实现这一点。

6.3.1 使用 Python 提示符进行测试

测试的第一种方式是在 Python 提示符中通过交互式窗口进行,就像我们在前面的章节中所做的那样。这种测试的优势在于它可以快速运行,并且你可以根据前一次测试的输出轻松地添加更多测试。我们迄今为止运行的测试是使用 Python 提示符进行测试的例子。例如,

>>> longest_word(['cat', 'dog', 'me']) 
'cat'

在运行那个测试时,如果你期望结果是 'cat',你会很高兴看到那个结果。然而,如果测试显示你的代码有误,你现在有机会回去修复它。

修复代码后,你将想要测试新的代码。这就是你可能会在使用 Python 提示符进行测试时出错的地方。当你回来测试你刚刚更改的代码时,你可能会倾向于只运行之前失败的测试用例。然而,在修复代码以正确处理失败的测试用例时,你可能会引入一个错误,导致之前已经通过的测试用例现在失败。你真正想要的是一种方法,不仅可以运行当前的测试,还可以运行所有之前的测试。

6.3.2 在你的 Python 文件中进行测试(我们不会这样做)

很有可能会把所有的测试用例都放在你的 Python 程序中(在函数外部,相当于主函数中),这样它们都可以运行。这种解决方案解决了我们刚才描述的 Python 提示问题,但它引入了新的问题。当你想要你的 Python 程序执行它被设计的主要任务而不是仅仅运行测试时会发生什么?你可以删除所有测试,但这样做的目的是在需要时再次运行它们。你可以将它们注释掉,以便将来运行它们,但这也不是一个很干净的办法。我们想要的,然后,是在我们想要的时候运行我们函数的所有测试,但仍然能够运行程序。要做到这一点,可以使用一个名为 doctest 的模块。

6.3.3 doctest

doctest 模块是 Python 内置的。使用 doctest 的好处在于,我们只需将测试用例添加到描述函数的文档字符串中。这个增强的文档字符串具有双重作用。首先,我们可以使用 doctest 在需要时运行所有这些测试用例。其次,它有时可以帮助 Copilot 在最初生成更好的代码,或者修复已经编写但不太正常工作的代码。让我们编写一个包含所有测试用例并准备好用 doctest 执行的longest_word函数(参见列表 6.2)。

列表 6.2 使用 doctest 测试longest_word函数
def longest_word(words): 
 ''' 
 words is a list of words

 return the word from the list with the most characters
 if multiple words are the longest, return the first 
 such word 

 >>> longest_word(['cat', 'dog', 'bird']) #1
 **'bird'** #1
 #1
 **>>> longest_word(['happy', 'birthday', 'my', 'cat'])** #1
 **'birthday'** #1

 **>>> longest_word(['happy'])** #1
 **'happy'** #1
 #1
 **>>> longest_word(['cat', 'dog', 'me'])** #1
 **'cat'** #1
 #1
 **>>> longest_word(['', ''])** #1
 **''** #1
 **'''** #1
    longest = ''                             #2
    for i in range(0,len(words)):            #2
        if len(words[i]) > len(longest):     #2
            longest = words[i]               #2
    return longest                           #2
**import doctest                  ** #3
**doctest.testmod(verbose=True)   ** #3

#1 显示 doctest 的测试用例

#2 显示函数的正确代码

#3 调用 doctest 执行测试的代码(在 main 中)

在这段代码中,我们看到文档字符串中提供了我们的测试用例作为 Copilot 的提示。Copilot 生成了正确的代码来实现这个函数。然后我们手动编写了代码的最后两行来执行测试。运行后,我们得到以下列表中的输出。

列表 6.3 列出 6.2 中程序运行时的 doctest 输出
Trying:
    longest_word(['cat', 'dog', 'bird'])  
Expecting:
    'bird'
ok              #1
Trying:
    longest_word(['happy', 'birthday', 'my', 'cat'])
Expecting:
    'birthday'
ok               #2
Trying:
    longest_word(['happy'])
Expecting:
    'happy'
ok             #3
Trying:
    longest_word(['cat', 'dog', 'me'])
Expecting:
    'cat'
ok           #4
Trying:
    longest_word(['', ''])
Expecting:
    ''
ok                   #5
1 items had no tests:          #6
    __main__                   #6
1 items passed all tests:          #7
   5 tests in __main__.longest_word
5 tests in 2 items.
5 passed and 0 failed.     #8
Test passed.

#1 longest_word函数的第一次测试通过

#2 longest_word函数的第二次测试通过

#3 longest_word函数的第三次测试通过

#4 longest_word函数的第四次测试通过

#5 longest_word函数的第五次测试通过

#6 主函数(函数外部)中没有测试。

#7 longest_word通过了所有测试。

#8 希望看到的是 0 个失败。

从这个输出中,我们可以看到每个测试都运行了,并且每个测试都通过了。这些测试之所以能运行,是因为我们在列表 6.2 中添加的最后两行:

import doctest
doctest.testmod(verbose=True)

在第一行,我们导入了 doctest 模块。这个模块帮助我们通过在运行程序时自动执行测试用例来测试我们的代码。在第二行,我们调用 doctest 模块中的testmod函数。这个函数调用告诉 doctest 执行所有测试;参数verbose=True告诉 doctest 为我们提供所有测试的结果,无论它们是否通过。如果我们切换到verbose=False,它只有在测试用例失败时才会给出输出(verbose=False实际上是默认值,所以你可以不带参数调用函数,它将默认不提供输出,除非一个或多个测试失败)。这可以是一个很好的特性,因为我们可以在 doctest 运行时保持其运行,并且只有在测试失败时才看到输出。

在这种情况下,我们的代码通过了所有测试用例。但让我们体验一下当我们的代码没有通过时会发生什么。

如果我们发现一个单词的长度与当前最长的单词相同,我们应该忽略它,因为我们总是希望在有多个单词长度相同时返回第一个最长的单词。这就是为什么在if语句中使用>(只有当新找到的最长单词确实比当前最长的单词长时才寻找新的最长单词)而不是>=是正确做法的原因。

我们可以通过将列表 6.2 中的>改为>=来中断代码,这将导致它选择最长单词的最后一个单词而不是第一个。让我们将以下行更改为

if len(words[i]) > len(longest):

更改为

if len(words[i]) >= len(longest):

现在,测试不应该全部通过。此外,让我们将最后一行更改为

doctest.testmod()

通过不对testmod函数提供任何参数,verbose现在被设置为False。当我们运行代码时,这是输出:

**********************************************************************
File "c:\Users\leo\Copilot_book\Chapter6\test_longest_word.py", 
line 12, in __main__.longest_word
Failed example:
    longest_word(['cat', 'dog', 'me'])
Expected:
    'cat'
Got:
    'dog'
**********************************************************************        
1 items had failures:
   1 of   5 in __main__.longest_word
***Test Failed*** 1 failures.

Doctest 方便地告诉我们哪个测试被执行了,预期的输出是什么,以及函数实际产生了什么。这将捕获错误并允许我们返回去修复错误。

Copilot 不会自动运行测试用例

我们经常听到以下问题:为什么 Copilot 在生成代码时不直接包含测试用例?例如,如果我们添加测试用例,如果 Copilot 能够尝试生成函数并且只提供通过那些测试用例的代码,那会很好。不幸的是,在这方面存在一些技术挑战,并且截至写作时,这个功能尚未包含。所以,如果你添加测试用例,它只是改善了 Copilot 的提示,但不能保证 Copilot 的代码建议通过那些测试。

到目前为止,我们已经看到了如何使用 Python 提示符和 doctest 运行我们的测试。现在我们知道如何测试我们的代码了,让我们考虑一下这如何修改我们的代码设计周期。

6.4 重新审视使用 Copilot 设计函数的周期

在第三章中,我们在图 3.3 中给出了如何设计函数的早期版本。在那个阶段,我们对检查我们的代码(我们在第四章和第五章中学习了)或如何测试我们的代码(我们现在知道的更多)了解得不多。因此,让我们创建这个周期的新的版本(图 6.1),以反映我们新的理解。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/6-1.png

图 6.1 使用 Copilot 的函数设计周期,增加了更多关于测试的内容

这个图比之前复杂一些,但如果我们仔细观察,我们可以看到保留了大部分原始过程。增加或改变的内容包括以下:

  • 当我们编写提示时,我们可能将 doctests 作为初始提示的一部分来帮助 Copilot 生成代码。

  • 经过第四章和第五章的学习,我们现在可以阅读代码来检查其行为是否正常,因此我们现在有一个额外的步骤来处理 Copilot 的初始代码看起来不正确的情况。如果发生这种情况,我们将使用 Ctrl-Enter 来探索 Copilot 的建议,希望能找到解决方案。如果我们能找到这样的解决方案,我们将选择它并继续前进。如果我们找不到,我们需要修改我们的提示来帮助 Copilot 生成改进的建议。

  • 在找到看起来可能正确的代码后,我们将运行 doctest 来查看代码是否通过了我们在提示中包含的 doctests(如果没有包含任何,它将自动通过)。如果 doctest 通过,那么我们可以继续添加测试并检查它们,直到我们满意代码正常工作。如果 doctest 失败,我们需要找出如何修改提示来解决失败的测试。一旦修改了提示,它可能会帮助 Copilot 生成新的代码,可能能够通过我们提供的测试。

使用这个新的工作流程,我们不仅能够确定我们的代码是否正常工作,而且在代码尚未正常工作时也能修复代码。在接下来的章节中,我们将提供更多的工具来帮助我们在代码不正常工作时进行操作,但到目前为止,让我们通过解决一个新的问题并使用这里描述的工作流程测试 Copilot 的解决方案来将这些内容综合起来。

6.5 完整测试示例

让我们尝试一个可能需要解决的问题,以帮助学生决定在面对面课程中坐在哪里。虽然我们谈论的是在教室中寻找空座位,但这个问题与尝试在音乐厅、剧院、电影院或办公室布局中找到空座位是相同的。

假设我们有一个教室布局,它是一个二维列表,包含座位行。每个座位要么包含一个空格(' '),要么包含一个学生('S'),分别表示空座位或占用座位。我们将要求 Copilot 创建一个函数来确定我们可以添加最多学生的行,在这个过程中,我们将设计检查代码是否正常工作的测试。

6.5.1 寻找我们可以添加到一行中的最多学生数

我们想找出我们可以在教室的单行中添加的最大新学生数。(等价地,我们正在寻找任何一行中的最大空座位数。)为此,让我们从一个有些含糊的提示开始,看看 Copilot 的表现如何。

列表 6.4 我们可以添加到一行中的最大学生数
def most_students(classroom):
 '''
 classroom is a list of lists
 Each ' ' is an empty seat
 Each 'S' is a student
 How many new students can sit in a row? #1
 **'''**
    max_students = 0
    for row in classroom:
        students = 0
        for seat in row:
            if seat == 'S':
                students += 1
            else:
                students = 0
            if students > max_students:
                max_students = students
    return max_students

#1 这部分提示有些含糊不清。

为了这个例子,我们的提示不是很清楚。“一行中可以坐多少新学生?”对于一个人类来说是一个合理的起点,但它不是很具体,很容易想象 Copilot 在解释我们的意思时会感到困惑。Copilot 可能会弄不清楚我们是计算每行的学生数还是空座位数,或者它可能会返回教室中可用的总座位数而不是一行的座位数。结果证明,我们的提示让 Copilot 感到困惑,代码并不正确,但在我们深入代码之前,让我们考虑我们应该运行哪些测试。我们已经想出了一套测试案例:

  • 一个教室中有一些连续的空座位和一些非连续的空座位,以确保它不仅仅是在计算连续的空座位数量

  • 一个没有空座位的教室,以确保在这种情况下返回 0

  • 一个行满空座的教室,以确保所有座位都被计算在内,包括第一和最后一个座位(边缘情况)

  • 一个有多行且每行空座位数相同的教室,以确保它只返回这些值中的一个(而不是,也许,所有这些行中空座位的总和)

让我们先添加第一个测试案例,并添加 doctest 代码来运行测试,如下所示。

列表 6.5 我们可以添加到一行中的最大学生数
def most_students(classroom):
 '''
 classroom is a list of lists
 Each ' ' is an empty seat
 Each 'S' is a student

 How many new students can sit in a row?

 >>> most_students([['S', ' ', 'S', 'S', 'S', 'S'], \ #1
 **['S', 'S', 'S', 'S', 'S', 'S'], \** 
 **[' ', 'S', ' ', 'S', ' ', ' ']])** 
 **4**
 **'''**
    max_students = 0
    for row in classroom:
        students = 0
        for seat in row:
            if seat == 'S':
                students += 1
            else:
                students = 0
            if students > max_students:
                max_students = students
    return max_students

**import doctest**
**doctest.testmod(verbose=False)**

#1 常见情况的 Doctest。在 docstring 测试案例中,如果需要换行,\是必要的。

当我们运行这段代码时,我们得到以下输出(我们手动清理了教室列表的格式,以帮助提高答案的可读性):

**********************************************************************
Failed example:
    most_students([['S', ' ', 'S', 'S', 'S', 'S'],
                   ['S', 'S', 'S', 'S', 'S', 'S'], 
                   [' ', 'S', ' ', 'S', ' ', ' ']])
Expected:
    4
Got:
    6
**********************************************************************
1 items had failures:
   1 of   1 in __main__.most_students
***Test Failed*** 1 failures.

虽然我们希望代码能正常工作,但我们欣赏第一个测试案例发现了错误。最多空座位的行是第三行,有四个座位可用。但 Copilot 的代码错误地告诉我们答案是六个。这很奇怪。即使不阅读代码,你也可能假设它是在计算每行的座位数或每行最多可坐的学生数。我们的测试案例中第二行有一排满的学生,所以很难判断。我们可以做的是将教室改为

 >>> most_students([['S', ' ', 'S', 'S', 'S', 'S'], \ 
 [' ', 'S', 'S', 'S', 'S', 'S'], \ #1
 **[' ', 'S', ' ', 'S', ' ', ' ']])** 
 **4**

#1 我们从第二行移除了第一个学生。

因此,现在第二行有五个学生。当我们再次运行代码时,测试再次失败,代码给出的答案是五。看起来代码不仅仅是在告诉我们每行的座位数。它一定是在做与学生们坐的位置有关的事情。我们的下一步是改进提示,确定我们是否能从 Copilot 那里得到更好的代码,但为了完整性,让我们首先在以下列表中解释代码实际上做了什么。

列表 6.6 对 Copilot 错误代码的遍历
 max_students = 0    #1
    for row in classroom:     #2
        students = 0             #3
        for seat in row:           #4
            if seat == 'S':        #5
                students += 1      #5
            else:                        #6
                students = 0             #6
            if students > max_students:     #7
                max_students = students    
    return max_students

#1 从 max_students 初始化为 0 开始

#2 对于教室中的每一行

#3 为这一行初始化学生计数器为 0

#4 对于行中的每个座位

#5 如果座位上有学生,增加计数器。

#6 如果有空座位,重置计数器。

#7 检查学生计数器是否比之前看到的更大,如果是,则将其作为新的最大值

你可以从代码描述中看到每行的操作,但从高层次来看,这段代码是在计算每行连续学生的数量。它是通过为每行初始化一个计数器为 0 来做到这一点的,只要它看到座位上有学生,就会增加这个计数器。一旦它看到空座位,它就会重置计数器。内循环末尾的 if 语句是跟踪之前看到的东西的最大值的一种相当标准的方式,在这种情况下,它是在跟踪之前看到的连续学生数量的最大值。这根本不是我们想要的,我们糟糕的提示部分要为此负责。然而,关键的部分是,我们的测试让我们知道代码是错误的。(如果你在阅读代码时发现了错误,那也很好!)

6.5.2 改进提示以找到更好的解决方案

让我们重写提示,保留测试用例,看看我们是否能在以下列表中做得更好。

列表 6.7 再次尝试找到最大的学生数
def most_students(classroom):
 '''
 classroom is a list of lists #1
 Each ' ' is an empty seat #1
 Each 'S' is a student #1
 #1
 Return the maximum total number of ' ' characters #1
 **In a given row.** 

 **>>> most_students([['S', ' ', 'S', 'S', 'S', 'S'], \**
 **[' ', 'S', 'S', 'S', 'S', 'S'], \**
 **[' ', 'S', ' ', 'S', ' ', ' ']])**
 **4**
 **'''**
    max_seats = 0
    for row in classroom:
        seats = row.count(' ')      #2
        if seats > max_seats:     #3
            max_seats = seats              #3
    return max_seats

**import doctest**
**doctest.testmod(verbose=False)**

#1 改进的提示明确表示我们想要任何给定行中 ’ ’ 字符的最大数量。

#2 count 是一个列表函数,它返回参数在列表中的数量。

#3 代码用于跟踪最大座位数

为了得到这个解决方案,我们必须使用 Ctrl-Enter 遍历可能的 Copilot 解决方案。其中一些解决方案现在计算了 ' ' 的连续出现次数,而其他一些,如列表 6.7 中的,通过了 doctest。奇怪的是,我们第一次尝试改进提示时,建议的解决方案是正确的。这再次提醒我们为什么 Copilot 输出的非确定性使得测试如此重要。

让我们花一分钟看看是什么让第二个提示比第一个提示更好。这两个提示都有

def most_students(classroom):
 '''
 classroom is a list of lists
 Each ' ' is an empty seat
 Each 'S' is a student

导致我们得到错误答案的提示部分是

How many new students can sit in a row?

得到正确答案的提示部分是

Return the maximum total number of ' ' characters in a given row. 

你永远无法真正了解像 Copilot 这样的大型语言模型(LLM)为什么会给出这样的答案,但让我们记住,它是被训练去仅根据它所接收到的单词以及训练数据中的单词(即 GitHub 上的大量代码)来预测下一个单词。

第一个提示要求 Copilot 进行一些推断,其中一些做得很好,而有些则不太理想。从某种意义上说,提示是在询问 Copilot 在列表的列表中知道什么是行。幸运的是,这在编程中非常常见,所以在这方面它没有问题。

然后,提示要求 Copilot 进行基本的逻辑步骤,推断出一个空座位是新生可以坐的地方。这正是 Copilot 遇到困难的地方。我们怀疑,因为我们询问的是新生坐在一行,它无法跳跃到意识到“新生”需要计算出你可以添加多少学生,换句话说,有多少空座位。相反,Copilot 专注于提示中的“学生在一行中”的部分,并开始计算每行的学生人数。它也可以使用函数名(诚然,可以更好;例如,max_empty_seats_per_row)来思考它需要计算最大学生数。这并不是我们想要的,但我们能理解 Copilot 为什么会犯这个错误。

现在,让我们来谈谈为什么,针对我们模糊的第一个提示,Copilot 决定计算给定行中的连续学生人数。也许在 Copilot 的训练数据中,连续学生人数是一个更常见的模式。也许是因为“坐在一行”可以被解释为“连续地坐”。或者也许是因为当我们编写这个示例时,我们一直在处理另一个要求连续空座位的版本的问题,Copilot 记得那次对话。我们不知道为什么 Copilot 给出了这个答案,但我们知道我们的提示太模糊了。

相比之下,我们的第二个提示在几个方面更为具体。首先,它明确要求最大值。其次,它要求一行中的空位或空座位的数量。这消除了 Copilot 推断空座位意味着新生座位的需求。我们还使用了“总数”和“给定行”来试图让 Copilot 摆脱其当前连续值计数的方法,但这并没有完全奏效。因此,我们最终不得不筛选 Copilot 的答案(使用 Ctrl-Enter),这些答案有时在寻找连续空座位,有时又找到了空座位的数量。

6.5.3 测试新解决方案

返回到我们的示例,因为新的代码通过了当前的测试,让我们给它更多的测试以确保它表现正确。在下一个测试中,我们将检查代码在没有任何行有空座位时是否正确返回 0:

    >>> most_students([['S', 'S', 'S'], \
                       ['S', 'S', 'S'], \
                       ['S', 'S', 'S']])
    0

下一个测试将确保代码正确地计算单行(这里指第二行)中的所有三个空座位,以避免出现边缘案例问题(例如,未能计算第一个或最后一个元素)。诚然,查看代码,我们可以看到正在使用count函数,并且因为这个函数是 Python 内置的,所以我们应该相当有信心这个测试会通过。然而,为了确保安全,测试它仍然是更安全的做法:

    >>> most_students([['S', 'S', 'S'], \
                       [' ', ' ', ' '], \
                       ['S', 'S', 'S']])
    3

最后的测试是为了检查 Copilot 是否正确处理了两行具有相同空座位的案例:

    >>> most_students([[' ', ' ', 'S'], \
                       ['S', ' ', ' '], \
                       ['S', 'S', 'S']])
    2

在添加了这些测试案例之后,我们再次运行了完整的程序,如下所示列表,所有测试案例都通过了。

列表 6.8 最大学生数量的完整代码和 doctests
def most_students(classroom):
 '''
 classroom is a list of lists
 Each ' ' is an empty seat
 Each 'S' is a student

 Return the maximum total number of ' ' characters in a 
 given row. 

 >>> most_students([['S', ' ', 'S’, 'S', 'S', 'S'], \
 [' ', 'S', 'S', 'S', 'S', 'S'], \
 [' ', 'S', ' ', 'S', ' ', ' ']])
 4
 >>> most_students([['S', 'S', 'S'], \
 ['S', 'S', 'S'], \
 ['S', 'S', 'S']])
 0
 >>> most_students([['S', 'S', 'S'], \
 [' ', ' ', ' '], \
 ['S', 'S', 'S']])
 3
 >>> most_students([[' ', ' ', 'S'], \
 ['S', ' ', ' '], \
 ['S', 'S', 'S']])
 2
 '''
    max_seats = 0
    for row in classroom:
        seats = row.count(' ')
        if seats > max_seats:
            max_seats = seats
    return max_seats

import doctest
doctest.testmod(verbose=False)

在这个例子中,我们看到了如何从头到尾编写一个函数来解决一个问题。Copilot 给出了错误的答案,部分原因是难以理解的提示。我们意识到它给出错误答案是因为代码在我们的第一次测试中失败了。然后我们改进了提示,并利用在前两章中学到的代码阅读技巧挑选出一个看起来符合我们需求的解决方案。新的代码通过了我们的初始基本测试,因此我们添加了更多的测试案例来查看代码是否在更多情况下都能工作。在看到它通过这些额外的测试后,我们有更多的证据表明代码是正确的。在这个时候,我们已经测试了常见的案例和边缘案例,所以我们非常自信我们的当前代码是正确的。关于测试,这个例子向我们展示了测试如何帮助我们找到错误,并且给我们更多的信心,相信代码将正常工作。

6.6 另一个完整的测试示例:使用文件进行测试

在大多数情况下,你将能够通过向文档字符串添加示例来测试你的代码,就像我们在前面的例子中所做的那样。然而,有时测试可能会更具挑战性。当你需要测试你的代码与某种外部输入进行交互时,这一点是正确的。一个例子是我们需要测试与外部网站交互的代码,但这在高级代码中比本书范围内你将创建的代码更常见。

本书范围内的一个例子是处理文件。当你的输入是一个文件时,你如何编写测试案例?Python 支持在文档字符串内部以这种方式进行,但为了与我们已经完成的内容保持一致,我们不会那样做。相反,我们将使用外部文件来测试我们的代码。让我们通过修改第二章中的 NFL 四分卫(QB)示例来看看如何做到这一点。

我们可以逐行通过一个示例,但由于我们对四分卫的查询仅针对文件的前九列,我们将去掉文件中剩余的列,以便使内容更易于阅读。在去掉剩余的列后,表 6.2 显示了文件的头四行。

表 6.2 NFL 数据集缩略版的头四行
game_id player_id position player team pass_cmp pass_att pass_yds pass_td
201909050chi RodgAa00 QB Aaron Rodgers GNB 18 30 203 1
201909050chi JoneAa00 RB Aaron Jones GNB 0 0 0 0
201909050chi ValdMa00 WR Marquez Valdes-Scantling GNB 0 0 0 0

在本例的剩余部分,我们将假设数据集的每一行只有这九个列,但我们希望想象如何处理整个数据集不会太困难(你只需在每个情况下添加所有额外的列即可)。

假设我们想要创建一个函数,该函数接受数据集的文件名和玩家名称作为输入,然后输出该玩家在数据集中取得的传球总码数。我们假设用户将提供的数据格式与第二章和表 6.2 中描述的 NFL 进攻统计数据文件格式相同。在我们编写提示或函数之前,我们应该如何测试这个?嗯,我们有一些选择:

  • *在更大的数据集中查找测试用例—*一个解决方案是将完整的数据集和多个玩家名称作为输入传递给函数。挑战在于确定我们是否正确。我们可以使用像 Google Sheets 或 Microsoft Excel 这样的软件打开文件,并使用电子表格功能来确定每个玩家的答案。例如,我们可以将文件作为 Excel 中的一个工作表打开,按玩家排序,找到玩家,并使用 Excel 中的求和函数来计算该玩家的所有传球码数。这并不是一个坏解决方案,但它也需要相当多的工作,而且如果你花足够的时间来找到测试答案,你可能已经满足了你的需求,不再需要 Python 代码!换句话说,确定测试用例的答案可能只是给你最初想要的答案,使得代码的价值降低。另一个问题是寻找你可能想要测试的所有边缘情况:你的数据集是否包含你想要测试的所有边缘情况,以便编写可以在以后的数据集上运行的程序?这种方法的另一个缺点是确定当函数执行比在多行中求和值复杂得多的操作时你应该做什么。在那里,确定一些真实测试值的答案可能是一项大量工作。

  • *创建用于测试的人工数据集—*另一个解决方案是创建已知多个可能查询答案的人工数据集。因为数据集是人工的,你可以添加边缘情况来查看代码在这些情况下的表现,而无需在真实数据集中找到这样的罕见示例。(有时真实数据集可能不包括这些边缘情况,但你仍然想要测试它们,以便代码在获取更新或新数据集时表现正确。)

考虑到在人工数据集中创建测试用例的优势,我们将继续采用这种方法。

6.6.1 我们应该运行哪些测试?

让我们思考一下我们想要测试的常见情况和边缘情况。对于常见情况,我们想要有一些测试:

  • 球员在数据集的不同行中多次出现(非连续的),包括最后一行。 这个测试确保代码在返回结果之前遍历了所有球员(即不会错误地假设数据是按球员姓名排序的)。

  • 一个球员在数据集中的连续行中出现。 这个测试确保没有某种错误导致连续值被跳过。

  • 一个球员在数据集中只出现一次。 这个测试确保即使只是累加一个值,求和操作也能正确执行。

  • 一个非四分卫球员也可能出现在数据集中。 为了这一点,我们确保代码包括所有球员,而不仅仅是四分卫。

  • 一个球员在一场比赛中总传球码数为 0。 这检查确保当球员没有传球码数时,代码的行为是正确的。这是一个常见的测试案例,因为球员可能会受伤并因伤错过比赛。

对于边缘情况,我们还想测试一些其他的事情:

  • 球员不在数据集中。 这实际上很有趣:在这种情况下,我们希望代码做什么?一个合理的回答是返回他们传球码数为 0。如果我们询问数据集中 2019 年至 2022 年勒布朗·詹姆斯(篮球运动员,不是足球运动员)在 NFL 的传球码数,0 是正确的答案。然而,这可能不是生产代码中最优雅的解决方案。例如,如果我们询问阿隆·罗杰斯(拼写错误为 Aron Rodgers)的传球码数,我们更希望代码告诉我们他不在数据集中,而不是他传球了 0 码数,这可能会在他在这个时间段内两次获得联盟最有价值球员时真正让我们困惑。为了表示名称缺失,我们可能返回一个很大的负值(例如,-9999),或者我们可能使用称为异常的东西,但这些超出了本书的范围。

  • 一个球员在所有比赛中总码数是负数,或者一个球员在单场比赛中码数是负数,以确保代码正确处理负值。 如果你不懂美式足球,这种情况可能发生,如果球员接球后被对方球员在起点(进攻线)后方擒抱。四分卫在整个比赛中传球码数是负数的情况不太可能发生,但如果他们传球一次导致码数减少(负码数)并且同时受伤,导致他们无法参加剩余的比赛,这种情况是有可能发生的。

现在我们已经有了想要测试的内容,让我们构建一个人工文件来捕捉这些测试用例。我们本可以将这些测试分散到多个文件中,这同样是一个合理的做法,但将它们全部放在一个文件中的优势在于我们可以将所有测试用例集中在一起。表 6.3 是我们构建并保存为 test_file.csv 的文件。

表 6.3 我们用于测试 NFL 传球码数函数的文件
game_id player_id 位置 球员 球队 传球完成数 传球尝试数 传球码数 传球达阵数
201909050chi RodgAa00 QB Aaron Rodgers GNB 20 30 200 1
201909080crd JohnKe06 RB Kerryon Johnson DET 1 1 5 0
201909080crd PortLe00 QB Leo Porter UCSD 0 1 0 0
201909080car GoffJa00 QB Jared Goff LAR 20 25 200 1
201909050chi RodgAa00 QB Aaron Rodgers GNB 10 15 150 1
201909050chi RodgAa00 QB Aaron Rodgers GNB 25 35 300 1
201909080car GoffJa00 QB Jared Goff LAR 1 1 –10 0
201909080crd ZingDa00 QB Dan Zingaro UT 1 1 –10 0
201909050chi RodgAa00 QB Aaron Rodgers GNB 15 25 150 0

注意这里的数据完全是虚构的。(这些不是任何球员的真实统计数据,正如你可以从丹和利奥现在神奇地成为 NFL 四分卫这一事实中看出。)我们仍然保留了某些真实球员的名字以及原始数据集中的真实game_idplayer_id。通常,使你的虚构数据尽可能接近真实数据是一个好主意,这样测试才是真实的,并且更有可能代表真实数据可能发生的情况。

让我们看看我们是如何将所有测试用例纳入这个测试文件(表 6.3)的。Aaron Rodgers 在文件中多次出现,既有连续的也有非连续的,并且作为最后一条记录。Jared Goff 也多次出现,我们在一场比赛中给他虚构了-10 码(作为一个精英 NFL 四分卫,我希望他能够接受我们给他虚构的糟糕单场比赛)。我们保留了从真实数据集中来的 Kerryon Johnson 作为跑卫(RB),并给他 5 码传球码数以确保解决方案不会仅过滤四分卫。Kerryon Johnson 在数据中只有一条记录。我们还添加了 Leo Porter 到数据集中,并给他 0 码传球码数(他肯定愿意做任何事情来避免被 NFL 球员擒抱)。我们还添加了 Dan Zingaro,并给他一个完成的传球,但传球码数为-10 码,覆盖了单个球员总码数为负的情况。表 6.4 显示了当我们为每位球员运行查询时,每个球员应该返回的内容。

表 6.4 测试用例中每位球员的传球码数
球员 测试用例中的传球码数
Aaron Rodgers 800
Kerryon Johnson 5
Leo Porter 0
Jared Goff 190
Dan Zingaro –10

6.6.2 创建函数

让我们从函数名开始,并在文档字符串中描述问题。这个问题可能足够复杂,以至于我们需要在函数内提供提示来帮助它知道如何打开文件、处理数据和关闭文件,但让我们先尝试描述函数,看看 Copilot 的表现如何。

列表 6.9 查找每个球员总传球码数的函数
def tot_pass_yds_player(input_file, player):     #1
 **"""**
 **input_file is a string that is the name of a file**
 **player is the name of a player**

 **The file is a csv file with a header row**
 **Column 4 is the player's name and column**
 **8 is the number of passing yards for that player**

 **return the total number of passing yards for the player**
 **"""**
    import csv
    with open(input_file, 'r') as csvfile:
        reader = csv.reader(csvfile)
        next(reader)
        total = 0
        for row in reader:
            if row[3] == player:
                total += int(row[7])
        return total

#1 我们缩短了函数名。

在查看生成的代码之前,我们想指出,我们没有包括这个示例的测试用例,因为它们可能不会对 Copilot 有所帮助。如果你的测试用例只是打开并使用人工文件,我们建议你在添加测试用例之前让 Copilot 生成代码。每个测试用例将只是一个文件名、一个球员和预期的输出。Copilot 可能无法读取与该文件名对应的文件,并推断出我们正在对第八列求和以获得所需的结果。这意味着给 Copilot 测试用例并不会提高提示的质量。事实上,我们数据的人工性质甚至可能使其困惑(例如,在什么情况下一个真正的 NFL 球员会有负的传球码数总额?)。

现在来看 Copilot 给出的代码,我们可以看到它相当合理,几乎与我们第二章看到的代码相同。鉴于我们阅读它时看起来合理,让我们看看如何测试它。

6.6.3 测试函数

为了测试这个函数,我们将扩展文档字符串以包含整个代码块的所有测试用例,如下所示。

列表 6.10 测试查找球员传球码数的函数
def tot_pass_yds_player(input_file, player):
 """
 input_file is a string that is the name of a file
 player is the name of a player

 The file is a csv file with a header row
 Column 4 is the player's name and column
 8 is the number of passing yards for that player

 return the total number of passing yards for the player

 >>> tot_pass_yds_player('test_file.csv’, 'Aaron Rodgers')
 800
 >>> tot_pass_yds_player('test_file.csv', 'Kerryon Johnson')
 5
 >>> tot_pass_yds_player('test_file.csv', 'Leo Porter')
 0
 >>> tot_pass_yds_player('test_file.csv', 'Jared Goff')
 190
 >>> tot_pass_yds_player('test_file.csv', 'Dan Zingaro')
 -10
 >>> tot_pass_yds_player('test_file.csv', 'Tom Brady')
 0 #1
    """
    import csv
    with open(input_file, 'r') as csvfile:
        reader = csv.reader(csvfile)
        next(reader)
        total = 0
        for row in reader:
            if row[3] == player:
                total += int(row[7])
        return total

import doctest
doctest.testmod(verbose=False)

#1 为数据集中未出现的球员添加额外的测试用例

我们运行了这段代码,所有的测试用例都通过了。(我们知道所有测试用例都通过了,因为 doctest 没有输出。)我们现在有额外的证据表明代码正在正常工作!

6.6.4 doctest 的常见挑战

让我们重写之前的提示,并在第一个测试中添加一个非常微妙的错误,如下所示。

列表 6.11 doctest 中的错误
def tot_pass_yds_player(input_file, player):
 """
 input_file is a string that is the name of a file
 player is the name of a player
 The file is a csv file with a header row
 The 4th Column is the player's name and the 8th column
 is the number of passing yards for that player

 return the total number of passing yards for the player

 >>> tot_pass_yds_player('test_file.csv', 'Aaron Rodgers')
 800 #1
 **>>> tot_pass_yds_player('test_file.csv', 'Kerryon Johnson')**
 **5**
 **>>> tot_pass_yds_player('test_file.csv', 'Leo Porter')**
 **0**
 **>>> tot_pass_yds_player('test_file.csv', 'Jared Goff')**
 **190**
 **>>> tot_pass_yds_player('test_file.csv', 'Dan Zingaro')**
 **-10**
 **>>> tot_pass_yds_player('test_file.csv', 'Tom Brady')**
 **0** 
    """
    import csv
    with open(input_file, 'r') as csvfile:
        reader = csv.reader(csvfile)
        next(reader)
        total = 0
        for row in reader:
            if row[3] == player:
                total += int(row[7])
        return total

import doctest
doctest.testmod(verbose=False)

#1 在 800 后面有一个不可见的额外空格。

当我们运行这段代码时,我们收到了以下错误:

Failed example:
    tot_pass_yds_player('test_file.csv', 'Aaron Rodgers')
Expected:
    800 
Got:
    800

乍一看,这似乎非常奇怪。测试用例期望得到 800,但它告诉我们失败了。好吧,事实证明我们在编写测试用例时犯了一个错误,写了“800 ”(末尾有一个空格)而不是“800”。这个错误导致 Python 认为空格很重要,并导致测试失败。坏消息是,这真的是一个在使用 doctest 时非常常见的问题!我们犯了这个错误比我们愿意承认的要多。好消息是,通过仅仅找到并删除空格就可以很容易地修复它。如果一个测试失败了,但 doctest 的输出表明它应该通过,那么请检查行尾是否有空格或多余的空格,或者与 doctest 期望的输出相比,你的输出中是否有任何缺失的空格。

由于所有测试用例都通过了,我们可以有信心回到更大的数据集并使用我们刚刚创建的函数。从这个例子中,关键的一点是我们可以,也应该创建人工文件来测试与文件一起工作的函数。再次强调,测试完全是关于获得代码正常工作的信心,你想要确保测试你编写的或由 Copilot 提供的任何代码。

在本章中,我们整体学习了代码测试的重要性、如何测试代码,以及如何在两个详细示例中实施。在我们的示例中,我们编写并测试了函数。但我们是如何决定编写哪些函数来解决更大的问题的呢?嗯,我们通过一个称为问题分解的过程来解决这个问题,我们将在下一章中详细讨论。

6.7 练习

  1. 给定以下场景,确定是封闭盒测试还是开放盒测试更合适,并解释原因:

    1. 测试员被给出一个函数规范,需要确保该函数在没有查看实现的情况下表现正确。

    2. 测试员需要通过编写针对由理解代码实现揭示的边缘情况的测试来调试函数。

  2. 这是一个带有一些测试用例的函数。确定以下哪些是常见用例,哪些是边缘情况:

def calculate_average(numbers):
    if not numbers:
        return 0
    return sum(numbers) / len(numbers)
# Test cases:
calculate_average([1, 2, 3, 4, 5])
calculate_average([10, 20, 30])
calculate_average([])
calculate_average([-1, -2, -3, -4, -5])
calculate_average([1000000, 2000000, 3000000])
calculate_average([3.5, 4.5, 5.5])
calculate_average([1, 2, "a", 4])
calculate_average([0])
    1. 我们正在构建一个分析网站流量的程序。网站流量以字典列表的形式表示。每个字典有两个键:"weekday"(表示一周中某天的字符串)和"visitors"(表示该天访问者的整数)。同一周中的某天可以出现在多个字典中。我们的目标是找到访问者数量最多的一天。

这是我们在 AI 代码生成工具中给出的初始提示:

def busiest_day(traffic_data): # traffic_data 是一个字典列表。# 找到最繁忙的一天。

工具生成了以下代码,但它似乎并不完全正确。

def busiest_day(traffic_data):
  most_visitors = 0
  busiest_day = None
  for day in traffic_data:
    if day["visitors"] > most_visitors:
      most_visitors = day["visitors"]
      busiest_day = day["weekday"]
  return busiest_day

你能解释为什么以及如何改进提示以获得所需的功能吗?

    1. 假设你有一个函数(find_highest_grossing_book(filename)),该函数分析图书销售数据并返回关于总收入最高的图书的信息。销售数据存储在一个 CSV 文件中,其中每行代表一次销售。CSV 文件中的列如下:

    2. title—售出图书的标题(字符串)

    3. author—图书的作者(字符串)

    4. price—图书的价格(浮点数)

    5. quantity—特定销售中售出的副本数量(整数)

创建一个包含find_highest_grossing_book函数(实现未在此提供)的 Python 脚本。包括一个文档字符串来解释函数的目的,并使用 doctest 模块添加测试用例。

    1. 审查提供的is_palindrome函数的测试用例。确定哪些测试用例是不正确的,并解释原因。提供正确的测试用例版本。
def is_palindrome(s):
    """
    Return True if string s reads the same forward and backward.
    >>> is_palindrome("racecar")
    True
    >>> is_palindrome("hello")
    False
    >>> is_palindrome("A man, a plan, a canal, Panama")
    True
    >>> is_palindrome("")
    True
    >>> is_palindrome("a")
    True
    >>> is_palindrome("Noon")
    True
    """
    cleaned = ''.join(char.lower() for char in s if char.isalnum())
    return cleaned == cleaned[::-1]
    1. 分析 find_max 函数的测试覆盖率。是否存在现有测试用例未覆盖的场景?如有必要,建议添加额外的测试用例。
def find_max(numbers):
    """ 
    Returns the maximum number from a list.
    >>> find_max([1, 2, 3, 4, 5])
    5
    >>> find_max([-1, -2, -3, -4, -5])
    -1 
    >>> find_max([42])
    42
    >>> find_max([])
    None
    """ 
    if not numbers:
        return None
    max_number = numbers[0]
    for num in numbers:
        if num > max_number:
            max_number = num
    return max_number

摘要

  • 在使用 Copilot 编写软件时,测试是一项关键技能。

  • 封闭盒测试和开放盒测试是确保代码正确性的不同方法。在封闭盒测试中,我们根据对问题的了解来制定测试用例;在开放盒测试中,我们还会检查代码本身。

  • Doctest 是 Python 内置的一个模块,它帮助我们测试代码。

  • 要使用 doctest,我们需要将测试用例添加到函数的文档字符串描述中。

  • 创建人工文件是测试使用文件功能的代码的有效方法。**

第七章:问题分解

本章涵盖

  • 理解问题分解及其必要性

  • 使用自顶向下的设计进行问题分解和编写程序

  • 使用自顶向下的设计编写拼写建议程序

在第三章中,我们讨论了为什么我们不应该要求 Copilot 解决大问题。想象一下,如果我们要求 Copilot“设计一个两人策略游戏”,会发生什么。

在最坏的情况下,Copilot 可能不会做任何有用的事情。我们有时会观察到 Copilot 反复给出评论,但从未提供真正的代码。

在最佳情况下,我们会得到一个预制的程序,其中所有决策都为我们做好了。这个程序可能不符合我们的需求。作为程序员的力量之一就是定制我们正在创建的内容。即使我们不想定制任何内容,如果 Copilot 的程序有缺陷,我们该怎么办?如果我们不理解一个大型程序,那么修复它将非常困难。

为了得到一个能够实现我们想要的功能的程序,我们需要向 Copilot 提供小的子问题,并将这些解决方案组装成我们自己的程序。本章的重点是学习如何将大问题分解成更小的子问题,这对于解决我们想要解决的大问题至关重要。

7.1 问题分解

问题分解涉及从一个可能没有完全定义的大问题开始,将其分解成更小的子问题,每个子问题都定义明确且对我们解决整体问题有用。我们的目标是编写一个函数来解决这些子问题。我们可能能够用一定量的代码解决一些子问题,但其他子问题可能仍然太大,无法用合理大小的函数来捕捉。 (在第三章中,我们提到我们希望保持函数简短——大约 12-20 行——以增加从 Copilot 获得良好代码、测试该代码以及在必要时修复代码中的错误的机会。) 如果一个子问题仍然太大,无法在一个函数中实现,那么我们将进一步将这个子问题分解成更小的子子问题。希望现在每个子子问题都足够小,但如果不是的话,我们也会继续分解它们!我们这样做的主要原因是管理复杂性。每个函数都应该足够简单,以至于我们可以理解其目的,并且 Copilot 可以很好地解决它。如果出了问题,我们不会有很多代码需要检查以找到问题。当我们编写极其复杂的代码时,我们很可能会犯错误。Copilot 也是如此!而且,当我们有长而复杂的代码中的错误时,它们并不容易找到和修复。问题分解对于编写高质量代码的重要性不容小觑。

从一个大的问题开始,并将其分解的过程被称为问题分解。我们在这里所做的方式与被称为自上而下设计的软件工程技术同义。它被称为自上而下设计,因为我们是从我们想要完成的大任务开始的,并将其分解成更小的任务。一旦我们完成了自上而下的设计,我们就可以在代码中实现产生的函数。我们将有一个用于整体问题的函数,它将调用每个子问题的函数。每个子问题函数将根据需要进一步调用它们自己的函数,以解决任何子子问题,依此类推。

正如我们在第三章中讨论的那样,我们希望最终得到每个函数都在我们的整体程序中扮演一个小角色,并且其行为是明确定义的。我们需要这些函数,以便我们可以调用它们来降低其他函数的复杂性。为了提高清晰度和易于理解,我们寻求设计具有少量参数并返回少量高度有用信息的函数。

7.2 自上而下设计的小例子

我们很快将跳入一个更真实的例子,展示自上而下设计是如何工作的,但首先我们想要使用我们之前的一些例子来设定场景。让我们思考一下我们在第三章中编写的函数的设计:get_strong_password。它反复提示用户输入密码,直到他们输入一个强密码。不要回去看那段代码——我们想要从这里开始。

假设我们想要使用自上而下设计来解决这个问题。如果它是一个小而明确的任务,我们可以直接将其实现为一个单独的函数。然而,对于这个任务,我们确实看到了一个子任务;也就是说,什么是强密码?围绕这个问题的规则是什么?对我们来说,这听起来像是一个我们可以尝试从这个函数中分离出来的子任务,以使其更简单。确实,在第三章中,当我们编写这个函数时,我们确实调用了我们之前的is_strong_password函数,它对密码是否强大做出了真/假的判断。

我们可以将这种自上而下的设计描绘如图 7.1 所示。为了便于在章节后面展示最终可能变得很大的图,我们将一致地从左到右展示设计,而不是从上到下,但同样的基本原理仍然适用。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/7-1.png

图 7.1 get_strong_password函数的函数图。get_strong_password调用is_strong_password

此图表明,我们的目标是让get_strong_password调用is_strong_password来完成其部分工作。

现在,让我们来看第二个例子。回想一下第三章,我们也编写了一个best_word函数,它接受一个单词列表作为参数并返回得分最高的单词。再次强调,不要回去看那段代码——我们想要在这里再次解决这个问题。让我们思考一下这个任务的代码可能是什么样子。它可能会使用一个循环来考虑每个单词,并在那个循环中需要跟踪迄今为止我们看到的最佳单词。对于每个单词,我们需要通过累加其每个字母的分数来确定它值多少分。记住,a值 1 分,b值 3 分,c值 3 分,d值 2 分,e值 1 分,以此类推。

哇,我们真的要深入探讨这个“每个字母值多少分”的问题。这听起来像是一个子任务。如果我们有一个可以调用的函数来告诉我们每个单词值多少分,我们就不需要在best_word函数中担心这个分数问题了。在第三章中,我们编写了一个名为num_points的函数,它正好执行这个子任务:接受一个单词作为参数并返回其总分数。我们可以从best_word中调用num_points,如图 7.2 所示。再次强调,这使我们的best_word任务变得更简单。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/7-2.png

图 7.2 best_word函数的功能图

在第三章中,我们恰好是从子任务到任务,从叶函数到父函数来编写这些函数的。我们将在本章继续这样做,但我们将首先进行自顶向下的设计,以确定我们需要哪些函数。

我们刚才在第三章中讨论的两个例子都很小,你确实可能能够通过一个函数快速编写它们的代码。但是,对于大型例子,问题分解是唯一控制复杂性的方法。

接下来,我们将深入探讨一个更大的自顶向下设计的例子。我们希望你在本章中掌握的关键技能是如何将一个大问题分解成更小的子问题。我们鼓励你多次阅读即将到来的例子。在你第一次阅读时,目标是了解各个部分如何组合起来解决整体问题。在你第二次阅读时,你可以自由地深入了解每个函数是如何独立工作的。

7.3 拼写建议

在本章的剩余部分,我们将使用自顶向下的设计方法从头到尾解决一个问题。我们希望你在自己解决问题时使用这种方法能够成功,所以你将在本书的其余部分看到自顶向下设计方法的重复出现。

哎呀——我们那里有误拼吗?Sucessful?那应该是successful。有时候英语单词的拼写可能很棘手!你可能遇到过很多这样的单词。是 thorough 还是 thourough?Acceptable 还是 acceptible?Receive 还是 recieve?我们将编写一个程序,该程序接受一个可能拼写错误的单词,并为此拼写错误提供可能的更正。这是一个针对单个单词的基本拼写检查器!

我们该如何想出这些可能的更正呢?好吧,让我们看看我们刚刚提供的单词对,并讨论它们所体现的错误类型。

我们在单词sucessful中犯的错误是漏掉了一个字母——我们需要在那里添加一个c来得到正确的单词successful。所以,总的来说,考虑向拼写错误的单词中添加一个字母似乎是个好主意,因为这可能是修复它的确切方法。我们将考虑在可能的任何位置添加任何可能的字母,而不仅仅是添加已经存在的字母的副本。这将帮助我们修复像acknowlege(缺少一个d)这样的拼写错误。

我们还需要考虑其他类型的错误。例如,在单词thourough中的错误并不是我们漏掉了一个字母,而是我们多了一个字母——我们需要删除第一个u来得到正确的单词thorough。因此,我们将考虑从任何拼写错误的单词中删除任何单个字母,看看是否可以修复它。

我们还能修复哪些错误?好吧,那个误拼的单词acceptible是一个新的错误类型:没有缺少或多余的字母,但有一个字母应该被改为另一个字母。也就是说,如果我们把i改为a,我们就能得到正确拼写的单词acceptable。为此,我们可以尝试将每个字母都改为字母表中的每个字母,看看是否可以修复误拼。

除了我们刚刚给出的三种方法之外,还有许多方法可以修复拼写错误。但我们将在这里停止,因为我们认为修复三种类型的错误对于我们的目的——展示自顶向下的设计——是足够的。这意味着我们的程序将无法纠正拼写错误的单词recievereceive,因为我们不会修复两个字母顺序错误的错误。我们也将无法纠正拼写错误的单词camoflaguecamouflage,因为我们只会修复拼写错误中的一个错误(camoflague有两个不同的错误,一个是缺少u,另一个是添加了u)。一旦你完成这一章,我们鼓励你在愿意更进一步的情况下继续改进你的程序,并学习更多关于纠正拼写错误的知识。

7.4 使用自顶向下的设计进行拼写建议

我们的任务是“编写一个程序,该程序接受一个可能拼写错误的单词,并为此拼写错误提供可能的更正。”这是一个很大的任务,我们绝对不希望将其强行塞入一个单独的函数中。

许多程序——无论是分析数据、提供拼写建议还是猜测未知文本的作者——都有三个不同的任务要执行。首先,是输入任务:我们需要获取程序运行所需的数据。其次,是处理任务,程序用这些数据做它应该做的事情。然而,数据处理得再好,如果用户不知道程序通过处理发现了什么,那么它也是无用的。这就是第三步,输出步骤,我们在这里向用户传达信息。表 7.1 总结了这一过程。

表 7.1 输入、处理数据和输出任务的总结
阶段 角色 拼写建议示例
输入 作为输入,提供函数所需的信息。 提供拼写错误的单词sucessful和一组真实单词(正确拼写的单词)。
处理数据 在该数据上执行函数指定的操作。 考虑可能使单词拼写正确的更改,例如,在sucessful中的c之前或之后添加一个字母c会产生正确的单词successful。许多其他错误的单词(例如,通过在u之前添加字母c得到的scucessful)也可能被尝试,但结果中只应包含真实单词。
输出 返回数据处理的结果。 返回建议“成功”。

你可以在第二章的数据处理示例中看到这个输入-处理-输出模型是如何工作的。我们需要从 CSV 文件中读取数据(这是输入步骤),确定每个四分卫的传球码数(这是处理步骤),然后输出四分卫及其传球码数(这是输出步骤)。

我们可以用类似的方式思考我们的拼写建议问题。这里令人惊讶的是,输入-处理-输出模型为我们提供了我们将在自顶向下设计中想要解决的三个子问题。我们的意思如下(也请参见表 7.1 中的示例):

  • 对于输入步骤,我们需要询问用户他们想要获取拼写建议的单词。

  • 对于处理步骤,我们需要找出用户单词的所有可能建议。

  • 对于输出步骤,我们需要告诉用户我们在处理步骤中找到的所有拼写建议。

注意,我们最初有一个大问题要解决(即整体的拼写建议问题),而现在我们有三个更小的问题要解决。我们主要或顶级函数最终将调用由此问题分解产生的任何函数。我们将把这个主要函数命名为spell_check

我们通常需要为每个识别出的子问题创建一个单独的函数,但这并不是绝对的。再次看看输入步骤。我们需要要求用户输入一个单词。虽然我们可以为这个子任务分离出一个单独的函数,但这将是过度设计。为什么?这是因为 Python 已经有一个内置的函数可以用来获取用户的输入!这个函数叫做input,我们在第三章第 3.3.7 节中看到它在工作,当时我们要求用户输入密码。

我们是否需要为输出步骤分离出一个单独的函数?不,再次不是!输出步骤只是将内容输出给用户。我们知道我们可以使用 Python 已经存在的print函数来做这件事。再次强调,为这个步骤分离出一个函数并不是一个错误,如果你自己进行这个问题分解,你可能已经这样做了。不过,你会注意到,这个函数会非常短,可能只是调用了一次print——在这个时候,你可能又会考虑是否真的需要将其作为一个单独的函数。

与之相比,这个过程步骤将涉及更多的工作。在确定所有可能的拼写建议时,需要做很多事情!我们必须支持删除字母、插入字母、将一个字母更改为另一个字母等等,这远远超出了在我们的主spell_check函数中保留所有这些操作的范围。我们需要一个单独的函数来处理这个过程步骤。这正是我们接下来需要努力的地方。

7.5 分解过程子问题

我们需要一个名字来命名实现过程步骤的函数。我们将称之为get_spelling_suggestions,因为它将负责返回用户输入的拼写建议。它当然需要将用户拼错的单词作为参数,否则它将无法访问它!

但是,在这里暂停一下:你认为这个函数需要任何额外的参数吗?答案:是的!这个函数必须以某种方式知道哪些字符串是英语中的真实单词。例如,它必须知道关于单词successfulthoroughacceptable以及成千上万的其它英语单词。我们可以通过几种方式来做这件事:我们可以传递一个包含真实单词列表或(Python)字典的参数,或者我们可以传递一个包含所有真实单词的文件的名称作为参数。当你设计你的函数时,你需要做出类似的决策,专注于函数完成其工作所需的输入以及完成时我们需要的返回值。

除了拼错的单词外,我们的函数还将接受一个参数,给出包含有效单词列表的文件的名称。这个文件中的每一行将有一个有效单词。在这本书的资源中,我们包括了一个名为 wordlist.txt 的样本单词列表文件,你可以使用它。(我们通过简单的网络搜索找到了一个免费的词典单词列表。)

我们需要对这个过程步骤做什么?我们可以将其视为四个子任务。这是更多的问题分解!这些子任务如下:

  • 从单词列表文件中获取单词列表。 单词文件是一个好的开始,但将单词放在 Python 列表中更方便。这样,我们可以轻松地确定一个字符串是否是有效单词。我们将把这个函数命名为 create_word_list

  • 生成用户字符串中所有可能的单词列表。 我们需要删除一个字母,插入一个字母,或者将一个字母改为另一个字母。这将生成许多字符串,其中一些是真实单词,而另一些则不是。例如,从字符串 sucessful 中,它会生成真实单词 successful,但也会生成字符串 xsuccesfulsucxcesfulsuccesfrl,这些显然不是真实单词。但没关系。目前,我们只想生成所有可能的单词,以免遗漏任何。我们将把这个函数命名为 all_possible_words

  • 使用所有可能的单词列表,生成仅包含真实单词的列表。 这是修剪我们潜在单词完整列表到实际存在于英语中的单词的步骤。我们将把这个函数命名为 all_real_words

  • 返回唯一单词的列表。 为什么是唯一的?我们不希望返回相同的拼写建议两次,即使可能有两种或更多种从用户字符串到达那个拼写建议的方法。例如,为了修复单词 sucessful,有两种方法:我们可以在已有的 c 前面添加缺失的 c,或者我们可以在 e 前面添加 c。两者都导致正确拼写的单词 successful,但我们只想保留那个单词一次。

如果我们将那个最终子任务——获取唯一单词列表——分解成它自己的函数,我们可以将其命名为 unique_wordsonly_unique_words。虽然我们可以将其分解出来,你这样做也是合理的,但我们决定将其保留为 get_spelling_suggestions 函数的一部分。原因是,在 Python 中,从列表中删除重复项只需要一行代码。为了方便,我们现在告诉你这个,但再次强调,如果你自己进行自上而下的设计,这也会是一个非常好的子任务。

多种分解问题的方法

没有一种单一的正确方法来进行自上而下的设计,也没有一种单一的正确方法将问题分解为子问题。例如,你可能对我们的决定感到惊讶,即首先生成所有可能的单词(包括实际上不是英语单词的假单词),然后将其修剪成实际存在的英语单词列表。为什么不先检查每个可能的单词,只有当它是真实单词时才将其添加到我们的列表中呢?我们当然也可以那样做!如果你已经有了这种替代分解的想法,或者我们激起了你的好奇心,我们鼓励你在完成本章后自己尝试这种替代设计。

在尝试自顶向下的设计并且如果设计不成功就放弃它,这并不是什么丢人的事情。设计不成功可能意味着什么?好吧,也许你发现很难将大函数分解成不同的子问题。或者也许你得到了几十个微小的函数,开始担心你的函数太具体,没有解决一般问题。或者也许你需要传递许多参数,其中一些可能只是为了传递得更远,直到最终一个函数需要它们。或者也许你只是想尝试一个不同的自顶向下的设计,看看结果如何!

我们鼓励你在确定代码的最终设计之前,在设计阶段进行实验。在这个实验中,你会尝试不同的可能函数以及函数的不同输入/输出。例如,我们之前讨论过get_spelling_suggestions函数是否应该接受包含真实单词的文件名或包含真实单词的列表(或字典)。在设计阶段,这两个选项都值得考虑。

这就剩下三个子任务我们需要解决。我们很快将对这些子任务进行自顶向下的设计。但首先,让我们看看我们现在处于什么位置(见图 7.3)。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/7-3.png

图 7.3 展示了get_spelling_suggestions的三个子任务的功能图

7.5.1 从单词列表文件获取单词列表

这个任务的函数是create_word_list。它将接受一个参数,即单词列表文件的名称,并将返回该文件中的单词列表。

我们是否需要对这个函数进行进一步的自顶向下的设计?让我们假设我们认为答案是肯定的。我们可以分解出哪些子任务?我们可以想象打开文件、读取其内容以及关闭文件的任务。但是,正如我们在第五章第 5.1.4 节中学到的,打开文件只需调用 Python 的open函数。同样,关闭文件只需调用 Python 的close函数。那么从文件中读取单词呢?这听起来并不比第五章第 5.1.4 节中读取 CSV 文件的行复杂多少。所以我们觉得有理由保留这个函数不变,不进行任何进一步的子任务分解。

7.5.2 生成所有可能单词的列表

这个任务的函数是all_possible_words,它将接受一个参数,即我们想要提供拼写建议的字符串。它将返回通过添加一个字母、删除一个字母或更改一个字母可以获得的所有可能单词的列表。

添加一个字母、删除一个字母和更改一个字母是三种不同的任务类型。而且,它们在我们看来并不特别简单:它们将涉及到对用户字符串中的字母进行某种形式的循环。啊!看起来我们在这个任务上需要进一步的自顶向下的设计。特别是,我们将从这个函数中分解出三个子任务:

  • add_letter—此函数将接受一个字符串参数,并返回所有可以通过在单词的任何位置添加一个字母而获得的所有字符串。

  • delete_letter—此函数将接受一个字符串参数,并返回所有可以通过删除一个字母而获得的所有字符串。

  • change_letter—此函数将接受一个字符串参数,并返回所有可以通过更改一个字母而获得的所有字符串。

表 7.2 提供了我们期望每个函数对两个不同输入字符串将返回的内容。对于add_letterchange_letter,返回了大量的字符串,因为它们将考虑在输入字符串的每个位置添加或更改英语中的每个字母。

表 7.2 add_letterdelete_letterchange_letter函数的示例
输入字符串 add_letter 返回的字符串 delete_letter 返回的字符串 change_letter 返回的字符串
cu acu, bcu, ccu, …, zcu cau, cbu, ccu, …, czu cua, cub, cuc, …, cuz u, c au, bu, du, …, zu, ca, cb, cc, …, cz
cad acad, bcad, ccad, …, zcad caad, cbad, ccad, …, czad caad, cabd, cacd, …, cazd cada, cadb, cadc, …, cadz ad, cd, ca aad, bad, dad, …, za cbd, ccd, cdd, …, czd caa, cab, cac, …, caz

与往常一样,思考是否需要从这三个函数中进一步分解出子任务是很重要的。然而,鉴于我们期望看到的是仅通过字母的循环,我们在这里可以推进,如果我们的假设被证明是错误的,我们再回头审视我们的自顶向下的设计。现在,我们可以告诉你,我们不需要进一步分解也能做得很好。

知道何时停止分解成子任务

通常情况下,知道何时停止将任务分解成更小的子任务,更多的是一种艺术而非科学。这需要通过设计程序来获得关于何时停止的直觉。许多经验丰富的开发者通常会为每个子任务暂停一下,考虑编程的复杂程度,并在心中勾勒出函数的步骤。如果解决方案不是直截了当的,他们通常会选择将其分解成更多步骤。但我们不期望你能够做到这一点。一些公司发布指南,通过建议允许的行数上限(例如,不超过 12 行)来鼓励简单的函数,但许多人认为限制应该更多地在于复杂性,尽管长度和复杂性当然是有关系的。

当你刚开始时,一个合理的代理方法是限制每个函数的行数在 12-20 行左右。如果你后来发现一个函数最终只有一行,它仍然是一个子任务,但可能不值得拥有自己的函数。(有时,如果它被不同的函数多次调用或有助于简化调用函数,保留一个短函数也是可以的。)

在最初练习时犯错是可以接受的;我们确实犯过。有时候一个子任务看起来会形成一个简单的函数,但最终却比预期的要困难得多。在这种情况下,您知道只需进一步细分它。同样,我们也有一些只有一行代码的函数,如果它们被频繁使用或使调用函数更容易理解,我们就保留那行代码作为一个函数。

7.5.3 生成所有真实单词的列表

这个任务的函数是all_real_words。它将接受两个参数:真实单词列表和所有可能的单词列表。它将返回一个仅包含完整可能单词列表中真实单词的列表。这个函数的代码将涉及遍历可能单词列表,并检查每个单词是否出现在真实单词列表中。由于这个任务是一个小而定义良好的任务,不会产生很多代码,所以我们很放心地保留这个函数,不做任何进一步的拆分。

7.6 自顶向下设计的总结

我们已经完成了自顶向下的设计过程。您可以在图 7.4 中看到我们的最终自顶向下设计。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/7-4.png

图 7.4 添加了all_possible_words三个子任务的函数图

在这里,我们不要因为树木而忽略了森林。如果我们退一步看,我们所做的是将原始的大问题分解成几个更小的问题,我们将每个问题都作为函数来实现。我们的原始spell_check问题可能感觉令人望而生畏。不过,没关系,因为我们将其分解为get_spelling_suggestions的一个主要子任务。get_spelling_suggestions函数本身也是一个大问题,但我们能够通过相同的过程来解决它:我们将其分解为三个子任务。其中两个子任务create_word_listall_real_words似乎可以作为一个函数解决,但另一个任务all_possible_words足够复杂,我们觉得它需要三个额外的子任务(add_letterdelete_letterchange_letter)。我们希望您从中学到的重要一点是,我们在每个步骤都使用了相同的问题分解技术,将最初令人畏惧的任务变成了一组可解决的子任务,这些子任务将成为函数。

我们下一步是从设计转向实施。我们知道现在需要哪些函数来解决我们的问题。是时候编写代码了!

7.7 实现我们的函数

当你进行自顶向下的设计时,就像我们刚才做的那样,你从整体问题开始,将其分解为子问题。这就是为什么我们从spell_check(我们的整体问题)开始,最终达到了像add_letter这样的函数,这些函数不需要进一步分解。但是当我们要求 Copilot 实现这些函数时,我们不会按照相同的顺序工作。相反,我们将以相反的顺序工作,首先做最小的子问题,然后转向依赖于这些子问题的函数。这对应于在如图 7.4 所示的图中从右到左移动。

我们以这种方式以相反的顺序实现函数,这样 Copilot 在我们想要实现较大函数的时候就会了解较小的函数。这样,Copilot 更有可能按照我们的期望调用我们的子任务函数。

我们希望在这里完全专注于问题分解,为此我们已经做出了一些决定。首先,虽然我们将在文档字符串中包含一些测试,但在这个例子中,我们不会像在第六章中那样追求完整的测试。我们鼓励您使用 doctest 运行我们提供的测试,并添加您自己的测试以增加对代码的信心。其次,我们没有过多关注我们的提示工程,而是专注于产生良好结果的提示。在下一章中,我们将专注于调试,那时我们将回到提示工程。第三,我们不是专注于全面阅读和理解代码。话虽如此,我们包含了一些注释来解释代码正在做什么以及它是如何工作的。

7.7.1 create_word_list

我们将从我们的create_word_list函数开始。正如第三章中所述,我们编写函数头(def行)和文档字符串,Copilot 填写代码。这就是我们将让 Copilot 编写本章中所有函数代码的方式。

我们已经知道我们的create_word_list函数应该做什么:从单词列表文件中读取单词,并以 Python 单词列表的形式返回它们。我们仔细地在文档字符串中写下我们想要的内容,如列表 7.1 所示。

列表 7.1 读取单词列表的函数
def create_word_list(filename):   #1
 **'''** #2
 **filename is the name of a file that has one English word per line.**

 **Return a list of the words in the file.**
    '''
    file = open(filename)        #3
    words = []
    for line in file:              #4
        words.append(line.strip())    #5
    file.close()
    return words

#1 我们自己编写的头

#2 我们自己编写的文档字符串

#3 打开文件

#4 遍历文件的每一行

#5 将每个单词添加到我们的单词列表中

我们使用了一个描述性的参数名称,filename,这为参数的目的提供了一个很好的提示。我们还小心地在这个文档字符串中使用这个参数名称。我们的文档字符串还明确指出,我们希望返回列表(而不是,比如说,打印它)。

我们没有在函数的文档字符串中包含测试,这是因为我们不希望分散实现我们每个函数的整体目标。尽管如此,如果你想要测试,你知道如何做!具体来说,我们在第六章中这样做,在那里我们创建了一个小文件用于测试目的。在这里,你可以创建一个包含每行一个单词但只有几个英语单词的小文件,然后测试从文件中读取的单词列表是否与放入文件中的单词匹配。我们的 create_word_list 函数已经完成,所以我们可以像图 7.5 中那样将其标记为完成。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/7-5.png

图 7.5 完整函数图,create_word_list 已完成

为了节省空间,我们在实现每个函数后不会展示更新后的图形,但我们会偶尔展示。

7.7.2 添加字母

现在我们将处理 all_possible_words 所需要的函数,从 add_letter 函数开始。add_letter 函数接受一个字符串作为参数,并返回通过向该字符串添加一个字母可以形成的潜在单词列表。让我们简要思考一下这个函数的预期输出,通过一个快速示例。如果我们给这个函数字符串 cu(如表 7.2 所示),它将生成一个列表,其中包含每个字母添加到每个可能位置的字词。这意味着它应该包括在 cu 前添加 a 以形成 acu,在 cu 前添加 b 以形成 bcu,以及对于 cu 前面的字符,直到 zcu。该函数还应包括 cu 之间的每个字母,以形成 caucbuccu…,直到 czu。最后(因为有三个可能的位置可以添加字母),我们期望该函数在 cu 之后添加每个可能的字母以形成 cuacubcuc…,直到 cuz。我们的 Copilot 提示和 Copilot 生成的代码显示在列表 7.2 中。

列表 7.2 添加任何可能字母的函数
def add_letter(word):
 '''
 word is a string with at least one letter.

 Return a list of all the strings that can be obtained by
 adding one letter to word.
 '''
    words = []
    for i in range(len(word) + 1):         #1
        for c in 'abcdefghijklmnopqrstuvwxyz':  #2
            new_word = word[:i] + c + word[i:]    #3
            words.append(new_word)       #4
    return words

#1 遍历每个索引,从 0 到最后一个字符之后

#2 遍历每个小写字母

#3 通过在索引 i 处放置字符 c 来形成新单词

#4 将这个新单词添加到我们的单词列表中

这里的代码相当微妙,需要仔细测试。例如,注意外循环使用 len(word) + 1,而不是更标准的 len(word)。如果没有 + 1,我们将在单词字符串的每个现有索引处添加字符。但实际上,我们会错过我们还想能够在现有字母之后添加字母的事实!+ 1 添加了一个额外的迭代,在这个迭代中我们在字符串的末尾添加了一个字符。

对于外循环的每个索引,我们在内循环中考虑每个可能的 lowercase 字母。代码行 new_word = word[:i] + c + word[i:] 使用了字符串切片技术,即通过两个索引从字符串中提取字母,以将当前内循环字符添加到当前外循环位置。

虽然我们在本章中花费在测试上的时间不多,因为重点是问题分解,但你仍然想通过给这个函数一个单个的拼写错误单词(即 cu)来测试它,然后打印返回的单词并确保它包括诸如 acu(在开头添加),cau(在中间添加),和 cua(在末尾添加)这样的字符串,以及可能的真实单词如 cupcut

注意,我们不想在文档字符串中包含确切的测试,因为这个函数返回的列表非常大!例如,尝试在 Python 提示符中输入以下内容:

>>> add_letter('cu')

你会得到一个包含 78 个字符串的列表!这是针对我们微小的 'cu' 字符串。随着参数字符串中字符数量的增加,列表中返回的字符串数量会显著增加。

7.7.3 delete_letter

add_letter 类似,delete_letter 函数接受一个字符串作为参数并返回一个潜在单词的列表。不同之处在于,delete_letter 不是添加一个字母,而是从字符串中删除每个可能的字母以到达新的可能单词。让我们思考我们期望这个函数做什么。如果我们给它字符串 “carf”,它应该尝试删除每个字母以产生字符串 “arf”(通过删除 “c”),“crf”(通过删除 “a”),“caf”(通过删除 “r”),和 “car”(通过删除 “f”)。同样,我们为这个函数提供了提示,如列表 7.3 所示。

列表 7.3 删除任何字母的函数
def delete_letter(word):
 '''
 word is a string with at least one letter.

 Return a list of all the strings that can be obtained by
 deleting one letter from word.

 >>> delete_letter('carf')
 ['arf', 'crf', 'caf', 'car']
 >>> delete_letter('a')
 ['']
 '''
    words = []
    for i in range(len(word)):        #1
            new_word = word[:i] + word[i + 1:]      #2
            words.append(new_word)     #3
    return words

#1 遍历每个索引,从 0 到最后一个字符

#2 通过删除索引 i 处的字符形成新词

#3 将这个新词添加到我们的单词列表中

这段代码与我们的 add_letter 函数代码类似。一个不同之处在于,这次我们只有一个循环,而不是两个。原因是,我们不需要内循环来遍历字母 abcd 等等。那是因为我们是在删除单词中已经存在的字母,而不是确定要添加哪个字母。

7.7.4 change_letter

现在,是我们 all_possible_words 的第三个也是最后一个子任务 change_letter 的时候了!这个函数负责生成将字符串中的一个字母更改为其他字母的单词。在思考我们希望在这里看到什么时,让我们再次考虑字符串 cu(如表 7.2 所示)。我们希望这个函数将字母 c 替换为所有可能的字母以创建字符串 aubudu… . ,以及将字母 u 替换为所有可能的字母以创建字符串 cacbcc… . ,cz。 (注意,我们省略了尝试 cu,因为那是初始字符串,我们只考虑字母的变化。)请参阅列表 7.4 中的提示和 Copilot 的代码。

列表 7.4 修改任何字母的函数
def change_letter(word):
 '''
 word is a string with at least one letter.

 Return a list of all the strings that can be obtained by
 changing one letter to another letter in word.
 '''
    words = []
    for i in range(len(word)):          #1
        for c in 'abcdefghijklmnopqrstuvwxyz':       #2
            if c != word[i]:                             #3
                new_word = word[:i] + c + word[i + 1:]    #4
                words.append(new_word)    #5
    return words

#1 遍历每个索引,从 0 到最后一个字符

#2 遍历每个小写字母

#3 如果 c 与索引 i 处的字母不同

#4 通过将索引 i 处的字符更改为字符 c 形成新词

#5 将这个新词添加到我们的单词列表中

这段代码与我们add_letter的代码非常相似!主要区别在于我们使用字符串切片不是添加新字符,而是更改现有字符。

到目前为止,我们已经完成了实现all_possible_words所需的三个函数。查看图 7.6,我们标记了我们所取得的重大进展!接下来,我们将处理all_possible_words

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/7-6.png

图 7.6 完整的功能图,all_possible_words辅助函数已完成

7.7.5 all_possible_words

我们现在能够实现all_possible_words的原因是我们已经实现了all_possible_words完成其工作所需的三个子任务函数。当我们要求 Copilot 编写这段代码时,我们期望看到对add_letterdelete_letterchange_letter的调用。查看列表 7.5,你会发现 Copilot 确实按照我们给出的提示做了这件事。

列表 7.5 生成所有可能单词的函数
def all_possible_words(word):
 '''
 word is a string with at least one letter.

 Return a list of all the strings that can be obtained by
 adding one letter to word, deleting one letter from word,
 or changing one letter in word.
 '''
    return add_letter(word) + delete_letter(word) \    #1
             + change_letter(word)    #2

#1 调用辅助函数来添加字母和删除字母

#2 调用辅助函数来更改字母

生成所有可能的单词并非易事。然而,我们在这里用一行 Python 代码就做到了!它只是调用了三个辅助函数,就是这样。这正是我们进行自顶向下设计的原因:通过将大部分复杂性转移到辅助函数,使复杂函数的实现变得更加容易。

7.7.6 all_real_words

我们接近能够实现get_spelling_suggestions,但还不是完全接近,因为我们首先需要实现它的子任务函数all_real_wordsall_real_words函数接受两个参数。第一个参数是英语单词列表(这将从英语单词列表文件中获取)。第二个参数是可能的单词列表(这将从all_possible_words中获取)。该函数返回可能的单词列表,这些单词是真实单词。让我们来做这件事!

列表 7.6 生成所有真实单词的函数
def all_real_words(word_list, possible_words):
 '''
 word_list is a list of English words.
 possible_words is a list of possible words.

 Return a list of words from possible_words that are in word_list.
 >>> english_words = ['scarf', 'cat', 'card', 'cafe']
 >>> possible_words = ['carfe', 'card', 'cat', 'cafe']
 >>> all_real_words(english_words, possible_words)
 ['card', 'cat', 'cafe']
 '''
    real_words = []
    for word in possible_words:       #1
        if word in word_list:           #2
            real_words.append(word)    #3
    return real_words

#1 遍历可能的单词

#2 这个单词是真实单词吗?

#3 是的:将单词添加到我们将返回的真实单词列表中

文档字符串中的测试是一个很好的例子,说明了这个函数是如何工作的。它使用english_words列表作为第一个参数,possible_words列表作为第二个参数。函数将返回possible_words中也在english_words中的那些单词。我们使用这个非常小的测试案例,而不是使用成千上万的英语单词,因为这使我们能够更容易地手动确定正确的返回值!

让我们检查一下图 7.7 中我们的进度如何。我们接近完成了——只剩下两个函数需要完成!

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/7-7.png

图 7.7 完整的功能图,all_possible_wordsall_real_words已完成

7.7.7 get_spelling_suggestions

我们已经实现了create_word_listall_possible_wordsall_real_words,这些是get_spelling_suggestions需要的唯一三个子任务函数。这意味着是我们实现get_spelling_suggestions的时候了!

这个函数接受两个参数:包含真实单词的文件名(每行一个单词)以及我们想要提供建议的字符串。它返回唯一建议的列表。像往常一样,当审查 Copilot 的非叶函数代码时,你想要检查它是否调用了你期望它调用的辅助函数,如列表 7.7 所示。在你继续之前,请确保它调用了它应该调用的所有辅助函数。

列表 7.7 返回拼写建议的函数
def get_spelling_suggestions(word_file, possible_word):
 '''
 word_file is the name of a file that has one English word per line.
 possible_word is a string that may or may not be a real word.

 Return the list of all possible unique corrections 
 for possible_word.
 '''
    word_list = create_word_list(word_file)        #1
    possible_words = all_possible_words(possible_word)        #2
    real_words = all_real_words(word_list, possible_words)    #3
    return list(set(real_words))    #4

#1 获取英语单词列表

#2 获取可能的单词列表

#3 确定哪些可能的单词是拼写建议

#4 返回唯一的拼写建议列表

那最后一行有点棘手。显然,它确保我们只返回唯一的单词——但是如何做到的呢?让我们问问 Copilot 来解释!当我们使用/explain命令询问 Copilot 时,我们得到了以下解释:

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/chatGpt.pngCopilot 这行 Python 代码执行了两个操作:

  1. set(real_words):这是从real_words列表创建一个集合。在 Python 中,集合是一个无序的唯一元素集合。因此,这个操作是从real_words列表中删除任何重复的元素。

  2. list(set(real_words)):这是将集合转换回列表。结果是所有元素都是唯一的列表。

因此,总结来说,这一行代码是从real_words列表中删除任何重复的单词。

7.7.8 spell_check

我们已经到达了我们的最终功能,spell_check!它接受一个参数:一个表示单词列表文件名称的字符串。它会要求用户输入一个字符串,然后显示该字符串的所有拼写修正。

列表 7.8 与用户交互的主要函数
def spell_check(word_file):
 '''
 word_file is the name of a file that has one English word per line.
 Ask user for a word.
 Print all possible corrections for the word, one per line.
 '''
    word = input('Enter a word: ')                          #1
    suggestions = get_spelling_suggestions(word_file, word)    #2
    for suggestion in suggestions:     #3
        print(suggestion)    #4

#1 要求用户输入一个字符串

#2 获取用户的拼写建议

#3 遍历拼写建议

#4 在单独的一行上打印每个拼写建议

请参考图 7.8,以确认我们已经实现了所有我们的函数!

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/7-8.png

图 7.8 完整功能图,所有功能均已完整!

你需要在你的 Python 程序底部添加一行代码来实际调用这个函数。否则,你的程序将不会做任何事情,因为没有函数被调用!所以,在底部添加这一行:

spell_check('wordlist.txt')

现在,假设你已经在你的目录中有了 wordlist.txt 文件以及你的 Python 程序,你可以运行它!它会要求你输入一个单词。尝试输入拼写错误的单词sucessful(这一切的起点!),你应该看到程序提供拼写建议successful,如下所示:

Enter a word: sucessful
successful

也尝试其他拼写错误的单词,例如 thouroughacceptible。到目前为止,由于只有一个真正的单词与我们要尝试的单词只有一个编辑距离,我们的程序只回复了一个拼写建议。我们鼓励你尝试输入单词 carf,看看我们的程序能否提供许多可能的拼写建议。

恭喜!你已经完成了你的第一个真正的自上而下的设计。你的程序为单词提供拼写建议,就像拼写检查器一样。我们通过一些前期设计工作,将原始问题分解成更小的子问题,使得问题实现起来变得容易得多。

许多计算机科学家认为问题分解是编写良好软件所需的最关键技能[1]。我们在本章中看到了问题分解的价值:它通过将其分解成更小的步骤,直到每个步骤都更容易解决,使得大问题变得可解。我们通过本章使用自上而下的设计(从大任务开始,将其分解成更小的任务)来应用这项技能。当使用 Copilot 和 ChatGPT 等工具时,这项技能仍然至关重要,因为它们在解决小而定义明确的问题时表现更好,而不是大问题。如本章开头所述,问题分解更多的是一种艺术,而不是科学,而且需要练习才能做得正确。我们将在接下来的章节中进行更多的问题分解,以帮助你更好地理解如何自己处理它。

7.8 练习

  1. 使用之前讨论过的 get_strong_passwordbest_word 的例子,让我们将自上而下的设计方法应用到新的问题上。假设我们想要编写一个名为 find_highest_scoring_word 的函数,它接受一个句子列表作为参数,并返回所有句子中得分最高的单词。每个单词的得分计算方式与 best_word 函数中的方式相同:

    1. 确定解决此问题所需的子任务。你会设计哪些单独的函数来将任务分解成更小、更易管理的部分?

    2. 绘制一个类似于图 7.1 和 7.2 的功能图,描述这些函数如何相互调用以解决整体问题。

  2. 我们讨论了有时一个任务足够简单,可以保持原样,即不分解成更小的任务。针对以下任务,决定你是否会将其分解成更小的子任务。如果是,列出子任务并解释原因。如果不是,解释为什么任务足够简单,可以保持为一个单独的函数。

    1. 读取文件并打印其内容

    2. 计算学生成绩列表的平均成绩

    3. 在数字列表中找到最大值

    4. 处理在线商店的订单,包括验证订单、计算总价、应用折扣和生成发票

  3. 为什么我们选择将create_word_list函数作为一个单独的函数来创建?这个任务能否作为更大函数的一部分保留?请解释你的理由。

  4. 假设你需要改变拼写检查器的工作方式。具体来说,你想要修改add_letter函数,以排除某些字母(例如,qxz)的添加。你将如何修改我们编写的程序?

  5. 以下函数处理订单列表,计算含税的总价,如果适用则应用折扣,并生成总结报告。

def process_orders(orders):
    total_price = 0
    for order in orders:
        price = order['price']
        quantity = order['quantity']
        total_price += price * quantity
    tax = total_price * 0.08
    total_price_with_tax = total_price + tax
    report = f"Total price: ${total_price:.2f}\n"
    report += f"Tax: ${tax:.2f}\n"
    if total_price_with_tax > 100:
        discount = total_price_with_tax * 0.1
        total_price_with_tax -= discount
        report += f"Discount: ${discount:.2f}\n"
    report += f"Total price with tax: ${total_price_with_tax:.2f}\n"
    print(report)

a = {'price': 20, 'quantity': 5}
lst = [a]
process_orders(lst)

通过将其分解成更小的子问题来重构(重新设计)process_orders函数。将每个子问题实现为一个单独的函数,并确保整体行为保持不变。

    1. 在这个练习中,你已经给出了一个代码片段,它已经被分解成两个函数:一个主函数和一个辅助函数。当我们使用以下代码中的测试用例 2 调用函数时,我们得到一个ZeroDivisionError错误。你的任务是根据提供的错误信息识别并修复错误。
def calculate_average(numbers):
    total = sum(numbers)
    count = len(numbers)
    return total / count

def process_numbers(data):
    valid_numbers = [n for n in data if isinstance(n, int)]
    average = calculate_average(valid_numbers)
    print(f"The average of the valid numbers is: {average}")

# Test Case 1
data = [10, 20, 'a', 30, 'b', 40]
process_numbers(data)

# Test Case 2
data = ['a', 'b']
process_numbers(data)

摘要

  • 我们需要将一个大型的编程问题分解成更小的子问题,然后才能有效地实现它。这被称为问题分解。

  • 自顶向下设计是一种将问题分解成小任务函数的系统技术。

  • 在自顶向下设计中,我们寻求解决定义明确的任务的小函数,并且这些函数可以被一个或多个其他函数使用。

  • 当我们准备实现从自顶向下设计产生的函数时,我们是从下往上实现的;也就是说,我们首先实现叶函数,然后是实现依赖于这些叶函数的函数,依此类推,直到我们实现了最顶层的函数。

第八章:调试和更好地理解你的代码

本章涵盖

  • 确定错误(bug)的来源

  • 使用 Copilot 技巧修复错误

  • 使用 Copilot 调试代码

  • 使用 VS Code 调试器查看代码如何运行

在每位程序员的职业生涯中,都会有一个时刻,他们的代码无法按照预期执行。这很可能已经发生在你身上了,请放心,这是学习编程的正常部分。我们如何修复代码?有时候,改变提示或像在前面章节中学到的那样更好地分解问题,就足以解决问题。但当你无法让 Copilot 提供不同或更好的代码,也无法弄清楚为什么你给出的代码无法正常工作时,你该怎么办?

本章有两个目的。主要目标是学习如何找到代码中的错误(称为 bug)并修复它们。为了找到这些 bug,你需要实现第二个目标,即在运行代码时更深入地理解代码的工作原理。

好消息是,代码中存在错误对程序员来说如此普遍,以至于编程环境,如 Visual Studio Code(VS Code),都有工具帮助我们揭示出了什么问题。我们将在本章学习如何使用这个工具,即调试器。

就像过去几章我们从小的例子开始一样,我们将通过一些小例子学习查找和修复错误的概念,然后我们将通过深入研究一个更大的例子来更真实地展示这个过程,这个例子与你未来可能编写的代码类似。

8.1 什么原因导致错误(bug)?

首先,是一个快速术语课程。许多年前,计算机使用继电器或真空管而不是晶体管。据说,Grace Hopper 博士在编程这些早期计算机之一时,遇到了由继电器中的实际虫子(蛾)引起的错误。今天,我们使用“bug”一词来指代程序中的错误,尽管我们不再处理真正的虫子了。现在,bug 是由程序员的错误造成的;当我们调试代码时,我们正在努力移除这些 bug。(Python 的实现或计算机硬件中也可能存在 bug,但它们非常罕见,可以安全地假设代码中的错误是因为你的代码。)

如果程序员真正试图解决问题,他们不会故意在代码中引入 bug。那么,bug 为什么会发生呢?好吧,软件工程师和 Copilot 会犯错误。你可能会问,什么样的错误?bug 主要有两大类:

  • 语法错误—这些错误发生在代码不符合 Python 语法要求时。例如,忘记在 for 循环的第一行末尾写上 : 就是一个语法错误。当你运行 Python 时,因为它正在尝试根据你的程序描述生成机器代码,所以会遇到错误,不知道该怎么办,并给出错误。有时,这些错误信息比其他错误信息更易读。当人们以传统方式学习编程,没有 Copilot 的帮助时,这些错误真的很常见。学习所有 Python 规则并使这些规则成为习惯需要一段时间。即使我们两个都写了数十年的代码,有时还是会犯语法错误。好消息是,使用 Copilot 编写代码几乎完全消除了这个问题!坏消息是,在两种类型的错误中,语法错误更容易找到和修复。

*** 逻辑错误—这些错误发生在程序中存在逻辑错误时。例如,也许代码的意图是计算一个单词列表中“Dan”这个单词出现的次数,但实际上代码计算的是列表中包含“dan”(忽略大小写)的单词数量。这段代码会在两个方面出错:它会将像 danDAN 这样的单词视为匹配项,尽管我们不想将它们视为匹配项,并且它会将 dan 视为一个单词的任何部分而不是整个单词。也就是说,它会计算像 Danieldangerdan 这样的单词,尽管我们不想计算这些单词中的任何一个!某个地方,代码没有做它应该做的事情,我们必须找出在哪里以及为什么。通常,找到逻辑错误是最困难的部分。一旦我们知道错误在哪里,我们就必须修复它,修复这些错误可能从更改单个字符到完全重写代码。逻辑错误可能发生在提示描述不佳或由于各种原因 Copilot 生成了错误的代码时。**

****## 8.2 如何找到错误

找到错误可能具有挑战性。从根本上讲,无论你或 Copilot 编写了代码,你在编写代码时都认为或 Copilot “认为”代码是正确的。这就是为什么通常更容易让同事而不是作者在代码中找到错误。作者看不到错误,因为他们创造了它!

在这本书中,错误对我们来说并不陌生,因为我们已经看到了代码中的错误,并且我们是通过阅读代码和/或测试来发现这些错误的。在前几章中,我们通过阅读代码找到了错误的来源。在这一章中,我们将解决你在测试中发现的错误,但似乎无法弄清楚代码为什么是错误的。通常,你可以通过尝试其他 Copilot 建议、使用新的提示来修复错误或要求 Copilot 为你修复错误来绕过找出代码错误的原因的过程,但根据我们的经验,这些技术并不总是有效。那么,我们需要更多的工具来帮助我们找出代码中的错误在哪里。

8.2.1 使用打印语句了解代码行为

从根本上讲,逻辑错误意味着作者认为代码会做什么和代码实际做什么之间存在不匹配。识别这种不匹配的常见方法是通过使用print语句来深入了解程序的行为,因为它们会告诉你计算机实际上在做什么。一个有用的做法是在不同的时间点打印变量,以查看这些变量在这些点的值。让我们尝试一下我们刚才给出的在单词列表中查找Dan的例子。以下是错误的代码。

列表 8.1 计算匹配单词的错误函数
def count_words(words):
    count = 0
    for word in words:
        if "dan" in word.lower():    #1
            count += 1
    return count

#1 将单词全部转换为小写字母的方法。

你可能已经看到了代码中的问题,但让我们假设我们不知道发生了什么,我们正在试图找出代码出错的地方。假设我们通过运行以下测试用例发现我们的代码是错误的:

>>> words = ["Dan", "danger", "Leo"]   
>>> count_words(words)
2

我们期望得到 1 的结果,但得到了 2。值得注意的是,将danger包含在这个测试用例中帮助我们发现了代码中的错误。代码中的问题出在哪里?为了找出这一点,我们可以添加print语句。当你想这样做时,你需要阅读代码以确定在哪里放置它们。查看这段代码,在函数开始时打印列表可能不是一个坏主意,取决于我们看到的错误,但这里的错误似乎与计数有关,而不是与单词列表具体有关。在for循环的第一行打印列表中的每个单词可能有所帮助,这样我们就可以检查代码是否正在处理每个单词。我们可能在返回之前打印count,但我们已经对返回的值有了很好的了解。这些都是合理的想法,可以帮助你更接近错误,但不是我们从哪里开始的。为了清楚起见,如果我们从这些其他想法中的一个开始,我们不会错;可能只需要再走几步就能找到错误。

因为错误在于将过多的单词计为Dan单词,所以我们会在count增加的地方的if语句内放置print语句,如下所示。

列表 8.2 查找错误的打印语句示例
def count_words(words):
    count = 0
    for word in words:
        if "dan" in word.lower():
            print(word,"is being counted")    #1
            count += 1
    return count

#1 用于显示哪些单词正在被计数的打印语句

使用相同的测试用例重新运行我们的代码,现在打印出来的结果是:

>>> words = ["Dan", "danger", "Leo"]   
>>> count_words(words) 
Dan is being counted
danger is being counted
2

哎!这会告诉我们,当程序不应该计数时,它却在计数单词危险。然后我们可以给 Copilot 一个新的提示,结合我们刚刚学到的东西,希望 Copilot 能修复这个问题。以下是我们在编辑器中添加的提示,以及 Copilot 在以下列表中修复的代码。

列表 8.3 使用提示修复已知的错误
def count_words(words):
    count = 0
    for word in words:
        # only count words that are exactly "Dan"    #1
        if word == "Dan":    #2
            count += 1       #2
    return count             #2

#1 导致 Copilot 生成正确代码的提示

#2 Copilot 根据提示添加的代码

一旦我们知道了这个错误,告诉 Copilot 如何修复它就变得容易多了。当然,这是一个基本的例子,但这个想法适用于更复杂的代码。这个过程通常是迭代的。你要求代码打印一些内容,然后打印的内容与你认为它应该执行的操作相符。然后你要求代码打印其他内容,并检查它是否符合你的预期。你继续这样做,直到有一个点,代码打印的内容与你想要它执行的操作不匹配。那时你就找到了错误,可以给 Copilot 一个提示来帮助它给你更好的代码,或者如果修复很简单,你可以直接更改代码。

根据代码的复杂性,使用print语句通常是调试的有效方法。我们通常将其作为调试的第一步。

调试:一门艺术而非科学

调试过程中的迭代是必要的,因为我们真的不清楚代码在做什么,以及为什么它不符合我们的预期。添加一大堆只告诉你最初认为发生了什么的print语句是可以的,因为每个这样的print语句都告诉你哪里不是要找错误的地方,这是一种有效的排除过程。找出哪里找错误需要时间和实践,所以当你刚开始时,如果你发现自己花了很多时间在上面,不要担心。

8.2.2 使用 VS Code 的调试器了解代码行为

VS Code 被新手和专业人士 alike 使用,因此它有工具可以帮助调试过程。因为它被专业人士使用,所以调试工具具有大量功能。为了本书的目的,我们将关注一些最常用的功能,但如果你对使用 VS Code 调试器感兴趣,可以查看更多资源(code.visualstudio.com/docs/editor/debugging)。

为了展示这些工具,我们将通过几种方式调试上一个例子中的同一个函数(参考列表 8.1)。列表 8.4 提供了我们将在这个部分中使用的代码。请注意,我们已经将count_words函数的测试添加到了程序中。

列表 8.4 用于调试的错误的count_words函数
def count_words(words): 
    count = 0 
    for word in words: 
        if "dan" in word.lower(): 
            count += 1 
    return count 

words = ["Dan", "danger", "Leo"]     #1
print(count_words(words))            #1

#1 直接调用count_words函数的代码

开始使用调试器和设置断点

要使用调试器,我们需要设置一个断点并启动调试器。设置断点告诉调试器在程序执行时你想开始调试程序的时间。一旦达到断点,你将能够检查变量值并逐行执行程序。断点非常有用。对于大型程序,你不想逐行执行整个程序,因为这可能需要大量时间。相反,使用断点,你可以只执行与你最相关的代码行。

要设置断点,将鼠标光标悬停在代码左侧,你会看到一个点出现。如图 8.1 所示,点击那个点,你就可以设置一个断点。对于你的项目,你可能想从在函数执行开始处创建一个断点开始,然后逐行执行。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/8-1.png

图 8.1 在 VS Code 中通过点击代码左侧创建断点

如果你将鼠标移开后,在断点左侧看到一个红色点,如图 8.2 所示,这意味着它已经正确设置。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/8-2.png

图 8.2 在 VS Code 中 count_words.py 文件第 2 行设置断点

你可以有多个断点,但在这个例子中,我们只保留第 2 行的单个断点。(注意,你可以通过再次点击那个红色点来移除断点。)接下来,我们将启动调试器并看看它是如何与断点一起工作的。

如何逐行执行代码

要启动调试器,选择运行>开始调试,如图 8.3 所示。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/8-3.png

图 8.3 在 VS Code 中启动调试器

调试器启动后,你应该看到一个类似于图 8.4 的屏幕。(如果你是第一次使用调试器,它可能会要求你选择调试配置,在这种情况下,你应该选择 Python。)

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/8-4.png

图 8.4 VS Code 中的调试视图

VS Code 调试器有几个组件[1]。在左侧,调试侧边栏包含变量部分、监视部分和调用堆栈部分。让我们简要地检查每个部分:

  • 变量部分包含在当前作用域内声明的变量(例如,在count_words内)及其当前值。例如,words参数被定义为包含['Dan', 'danger', 'Leo']的列表。你可以点击words左侧的箭头(>)来查看该变量的更多详细信息。这个部分非常有用,因为你可以检查每个变量的值。

  • 监视部分包含你想要特别监视的表达式。例如,你可能会将表达式"dan" in word.lower()添加到监视表达式中,并且你可以看到对于每个不同的word值,它是否为TrueFalse。要添加一个表达式,将鼠标悬停在监视部分上并点击加号(+)。

  • 调用堆栈部分 包含导致执行此代码行的已调用函数。在这里,主函数(由 VS Code 称为 <module>)在第 9 行调用了 count_words 函数。在 count_words 函数内部,我们目前在第 2 行。您可以通过查看当前突出显示为第 2 行的代码来验证这一点,如图 8.4 所示。

说到这里,在右侧,显示的是我们的代码编辑器,我们可以看到行 count = 0 被突出显示。这是当前尚未执行的代码行。该行尚未执行的原因是我们在这里设置了断点,由带有点的箭头表示,在代码的这一行。当我们启动调试器时,它运行代码直到即将执行 count = 0 之前。

调试工具栏(图 8.5)在开始调试后至关重要,因为它驱动了整个过程:您可以前进一个指令、重新启动调试或停止调试。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/8-5.png

图 8.5 调试工具栏

工具栏上的按钮,从左到右依次是

  • **继续(F5)—此按钮将执行到下一个断点执行时。在我们的示例中,count_words 的第 2 行永远不会再次执行,因此点击继续将导致程序和调试会话运行到结束。

*** 单步执行(F10)—此按钮将跳到当前函数中的下一行代码。单步执行意味着如果该函数中的某行代码调用了另一个函数(例如,当第 4 行上的 word.lower() 被调用时),调试器将保持在 count_words 函数中,并仅运行被调用的函数(例如,word.lower())直到其完成。* 进入(F11)—此按钮将跳到下一行代码,包括进入被调用的任何函数。与单步执行不同,当您使用进入时,调试器将进入当前函数调用的任何函数。例如,如果您在调用另一个函数的代码行上使用进入,它将进入该函数并从该函数内部继续调试(逐行)。默认情况下,它不会进入库函数调用(例如,word.lower() 是 Python 标准库的一部分),但会进入您编写的函数。* 退出(Shift + F11)—此按钮将执行代码直到当前函数结束,然后从该函数的退出之后继续调试。* 重新启动—此按钮重新启动调试过程。它将重新启动程序,程序将执行到其第一个断点。* 停止—此按钮停止调试过程。********

******#### 逐行执行代码

现在我们已经掌握了调试器的使用方法,让我们通过使用“单步执行”按钮继续我们的示例。让我们点击一次“单步执行”按钮,看看我们的视图如何变化(图 8.6)。单步执行跳到了下一行代码,即第 3 行。这意味着它执行了

count = 0

并且即将准备执行

for word in words:

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/8-6.png

图 8.6 点击一次“单步执行”后的调试器。下一行(第 3 行)现在被突出显示。

我们可以从界面的各种线索中看到,执行了count = 0这一行。首先,在右侧的编辑器中,我们可以看到for循环被突出显示,左侧的箭头指向这一行代码。在左侧的调用栈部分,我们可以看到它现在位于count_words的第三行(如count_words被突出显示,右侧椭圆中的数字读作 3:1)。也许最重要的是,在左侧的变量部分,我们现在可以看到变量count已被添加到局部变量中,其值为0。这一点非常令人惊讶,因为如果你试图逐行阅读代码并跟踪正在发生的事情,第 2 行的count = 0意味着创建了一个名为count的变量,并赋予了一个值为0。这正是 VS Code 调试器告诉我们的事情。我们希望你现在开始看到这个工具是多么强大。

让我们再次点击“单步执行”。现在我们在该行之前停止。

if "dan" in word.lower():

我们可以看到现在有一个新的变量word,其值被赋为"Dan",这正是我们所期望的:word被赋予了列表words中的第一个元素的值。这是一个很好的地方来指出,除了能够在变量部分读取变量的值之外,你还可以将鼠标悬停在已经声明的任何变量上,以查看其值。真是太棒了,不是吗?

让我们再次点击“单步执行”并看到if语句中的条件"dan" in word.lower()评估为True,因此我们将执行该行

count += 1

现在我们已经掌握了这个技巧,让我们继续点击几次“单步执行”。第一次点击“单步执行”,它将回到for循环,你可以看到count已经增加到1。第二次点击“单步执行”,它将停在if语句处,你可以看到单词现在是"danger"。我们可以在这里停止并添加一个观察表达式来查看这个if语句会做什么。要添加观察表达式,将鼠标悬停在“观察”部分,然后点击出现在“观察”右侧的加号箭头。这将允许你输入任何你想要的内容。我们输入了"dan" in word.lower()并按 Enter 键添加了这个观察表达式,如图 8.7 的调试侧边栏所示。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/8-7.png

图 8.7 添加观察表达式后的调试器视图

如果我们在这章的早期没有找到错误,那么这就是我们会找到错误的地方。表达式"dan" in word.lower()评估为True,这意味着count将再次增加。但我们只想精确匹配单词"Dan",不希望"danger"被计算在内!

这是一种完全合理的调试函数的方法。正如我们之前提到的,在函数执行的开始处放置断点,然后逐个步骤地执行,通常是调试的一个很好的起点。你可能只有在for循环运行了数千个值之后才犯错误时,才会发现这种方法有难度。为了解决这类挑战,我们可能会在特定的位置放置断点,以避免在调试器中花费大量时间。让我们停止调试器(在调试工具栏中点击停止),从第 2 行(点击行左侧的红点)移除断点,并尝试不同的断点。

使用断点进行选择性调试

这次,让我们在代码中我们想要更密切监控的点放置断点。根据我们的测试用例显示,当我们期望只有一个单词“counted”时,列表中的两个单词“counted”,我们应该尝试在count增加的行放置断点,就像我们在图 8.8 中所做的那样。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/8-8.png

图 8.8 在第 5 行放置我们的新断点后的代码视图

一旦我们开始调试器,代码将运行直到第一次if语句评估为True并且count += 1行准备执行。图 8.9 展示了启动调试器后的调试器视图。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/8-9.png

图 8.9 调试器遇到断点(第 5 行)第一次时的视图

我们在count增加的地方放置断点,因为我们想看看列表中的哪个项目导致了count的增加。检查我们的局部变量,我们可以看到word'Dan',这是我们想要count变量增加的时候。鉴于这是我们想要发生的事情,我们还没有找到我们的错误。

现在是我们真正可以利用断点放置的优势的时候了。我们希望代码执行直到再次遇到断点。要做到这一点,就是在调试工具栏中点击继续(参见图 8.5)。点击继续后,调试器应该像图 8.10 所示那样出现。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/8-10.png

图 8.10 调试器遇到断点(第 5 行)第二次时的视图

在变量部分,注意countword的值被突出显示。这是为了告诉你,因为你点击了继续,这两个值已经改变。在找到单词'Dan'后,count中的值如预期地增加到1word中的值更新为'danger'。尽管'danger'是单词列表中的下一个值,但我们不应该期望代码会为单词'danger'增加count。在这个时候,我们已经找到了我们的错误。注意,通过良好的断点放置,找到错误可以变得多么快。但我们警告说,知道放置断点的最佳位置就像知道调试时最佳打印变量一样——这需要时间和实践来学会如何做得好。

在本节中,我们看到了在 VS Code 中调试器是多么强大。设置断点并开始调试会话非常简单。当调试器监控函数中的变量时,你可以在代码执行时在调试侧边栏中检查这些变量。

8.3 如何修复 bug(一旦找到)

在传统的编程学习方法中,这将是简短的一节,告诉你自己修复 bug。尽管这仍然是一个选择,但现在我们有更多的选择与 Copilot 一起使用。这些选择包括请求 Copilot 修复它,为整个函数提供一个新的提示,以更好地描述问题并避免 bug,为 Copilot 提供一个有针对性的提示来修复你发现的代码中的错误部分,如果这是一个小修复,你自己进行修复。让我们看看我们如何做每一件事。

8.3.1 通过 Copilot Chat 请求 Copilot 修复你的 bug

在下面的例子中,我们将与 Copilot Chat 功能进行交互。Copilot Chat 功能允许你以类似于 ChatGPT 的方式与 Copilot 进行交互,但仅限于与编程相关的问题。如果你愿意,也可以像我们在本节中与 Copilot 交互一样,通过openai.com(在撰写本文时提供免费等级)以相同的方式与 ChatGPT 进行交互。我们更倾向于 Copilot Chat,只是因为它的界面内置在 VS Code 的最新版本和 Copilot 扩展中。

在之前的章节中,我们主要只是向 Copilot Chat 请求单个答案,而在这个章节以及之后的章节中,我们将使用 Copilot Chat 与工具进行对话。当我们与工具进行对话时,我们将努力区分我们写的部分(“Human”)和 Copilot 的响应(“GitHub Copilot”)。使用 Copilot Chat 功能,让我们看看 Copilot 能否在这里帮助我们:

LPHuman 我有一个函数,如下所示,该函数本应计算单词“Dan”在参数列表“words”中出现的次数。然而,它似乎是在计算列表中“dan”字符出现的次数。你能帮我修复这个问题吗?

*`*def count_words(words):*`* 
 *`*count = 0*`* 
 *`*for word in words:*`* 
 *`*if "dan" in word.lower():*`* 
 *`*count += 1*`* 
 *`*return count*`*

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/chatGpt.pngGitHub Copilot 当然可以!要计算单词“Dan”在列表中出现的次数,你可以修改if语句,检查整个单词是否等于“Dan”(不区分大小写)。以下是修改后的代码:

*`*def count_words(words):*`* 
 *`*count = 0*`* 
 *`*for word in words:*`* 
 *`*if word.lower() == "dan":*`* 
 *`*count += 1*`* 
 *`*return count*`*

这段代码将计算单词“Dan”(不区分大小写)在列表中出现的次数。

Copilot Chat 的回应改变了以下行

if "dan" in word.lower():

if word.lower() == "dan":

虽然这个变化看起来很小,但它有很大的影响。原始的代码行在word(在单词通过.lower()方法转换为全小写字母后)中搜索字符串"dan"的任何位置。新的代码行检查word(同样,通过.lower()方法转换为全小写字母)是否与字符串"dan"完全匹配。

Copilot 的此答案更接近正确解决方案,因为它只会匹配整个单词,而不是在单词的任何地方寻找dan的出现。然而,调用.lower()使其不区分大小写,因此它也会匹配dan,而不是我们之前(列表 8.3)的解决方案那样区分大小写(它必须正好是Dan)。我们希望区分大小写,因此我们可以继续使用 Copilot Chat,让 Copilot 为我们修复这个问题。

LPHuman 你能给我相同的代码,但只计算单词“Dan”(区分大小写)吗?

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/chatGpt.pngGitHub Copilot 当然!这是修改后的代码,它只计算单词“Dan”(区分大小写):

*`*def count_words(words):*`* 
 *`*count = 0*`* 
 *`*for word in words:*`* 
 *`*if word == "Dan":*`* 
 *`*count += 1*`* 
 *`*return count*`* 

此代码将计算单词“Dan”(区分大小写)在列表中出现的次数。

在这种情况下,当我们知道代码是如何出错的时候,Copilot Chat 能够为我们解决问题。我们没有明确指出哪一行代码是错误的,但如果你知道这一点,你也可以将这一点传达给 Copilot Chat。像我们这里这样知道哪一行是错误的优点之一是,我们可以确保 Copilot 的修复更改了错误的行。让我们继续,并检查其他修复错误的选项。

8.3.2 为整个函数提供 Copilot 的新提示

你已经在这本书中多次看到我们重写函数的提示了。这仅仅涉及重写函数提示,看看 Copilot 是否能做得更好,有时使用 Ctrl-Enter 来探索 Copilot 的解决方案。我们在这里不会重复,因为你已经多次看到过(例如,在第六章的第 6.5.2 节)。

8.3.3 为函数的一部分提供 Copilot 的针对性提示

我们在章节 8.3 中较早使用了这种技术。我们将其作为列表 8.5 重现。

列表 8.5 使用提示修复已知错误(重新列出)
def count_words(words):
    count = 0
    for word in words:
        # only count words that are exactly "Dan"     #1
        if word == "Dan":
            count += 1
    return count

#1 导致 Copilot 生成正确代码的提示

这里,我们已经确定上一行

if "dan" in word.lower():

没有做我们想要的事情。因此,我们移除了错误的代码,并特别添加了一个提示,明确指出我们想要 Copilot 做什么,并且它能够生成正确的代码。

8.3.4 修改代码以自行修复错误

这种修复错误代码的方法在本章中是新的。到目前为止,你在本书中已经看到了足够的代码,你很可能可以通过直接修改它来修复一些代码。例如,假设我们编写了以下代码。

列表 8.6 计算列表中指定数字的函数
def count_between(numbers, x, y):
    count = 0
    for number in numbers:
        if number >= x and number <= y:
            count += 1
    return count

我们在本书的早期部分已经看到过类似这样的函数。它遍历数字列表中的所有数字,并检查每个数字是否大于或等于x且小于或等于y。如果一个数字在xy之间,它会增加count。函数结束时,它返回count。如果目标是计算列表中在xy之间(包括xy)的数字,这里的代码并没有错误。

然而,“之间”这个术语可能有点含糊不清。你是想包括 x 和 y,还是只包括它们之间的值(不包括它们)?假设当你想象这个函数时,你想要不包括 x 和 y。例如,如果列表是 2、4、8,而你提供了 3 和 8 作为 x 和 y 的值,你希望计数为 1(包括 4,但不包括 8)。你可能已经看到了函数在这个目标上的错误以及如何修复它,但让我们假装你还没有意识到代码并不符合你的要求。

就像任何由 Copilot 或我们创建的函数一样,我们应该测试这个函数。幸运的是,在编写第一个测试用例时,我们在测试用例(numbers)中包含了一个等于 x(下限)和等于 y(上限)的值:

>>> numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> print(count_between(numbers, 3, 7))
5

在提示符下测试代码,我们发现我们的函数返回 5 作为答案,但我们认为答案应该是 3。根据我们对“之间”的定义,我们觉得只有数字 4、5 和 6 在 3 和 7 之间,所以答案应该是 3,即 3 和 7 之间有三个数字。相反,代码给出了 5 的答案。因此,我们知道它没有正确工作,多亏了我们的测试用例。无论我们是通过检查代码还是使用本章中的调试技术来继续寻找错误,我们都会意识到

if number >= x and number <= y:

是罪魁祸首。在这里,完整的修复需要我们将if语句从

if number >= x and number <= y:

to

if number > x and number < y:

这是一个我们可以直接做出的更改,无需请求 Copilot 的帮助(尽管 Copilot 可能也能在这里帮助我们)。列表 8.7 在更改后有修正后的函数。

列表 8.7 计算指定数字的函数(已修正)
def count_between(numbers, x, y):
    count = 0
    for number in numbers:
        if number > x and number < y:    #1
            count += 1
    return count

#1 手动将>=改为>和<=改为<

8.4 根据我们的新技能修改我们的工作流程

现在我们知道了如何更刻意地寻找和修复错误,让我们回顾一下我们在第六章中最后访问的工作流程。请注意,这个工作流程是关于如何设计一个单一函数的,所以它假设你已经完成了第七章中描述的功能分解,以确定适当的函数。新的工作流程出现在图 8.11 中。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/8-11.png

图 8.11 使用 Copilot 增强的功能设计周期,包括调试

虽然这个图开始变得有点复杂,但大部分都是你在第六章中看到的内容。这里唯一的改变是包括了调试。具体来说,如果你已经尝试修改提示,但仍然无法得到可以工作的代码,那么是时候尝试显式地进行调试了。凭借你在本章中获得的技术,你可能会成功地找到一些错误,但不是所有可能的错误。如果你找到了错误,并觉得你已经成功地修复了它,流程图会把你送回测试,以确保你的修复是有效的(并且没有破坏其他测试用例)。万一你无法通过新的提示使代码工作,而且似乎无法调试它,我们发现问题分解通常是下一步的最佳选择。换句话说,如果你无论多么努力都无法使一个函数工作,那么这个函数可能应该被分解成多个函数,你更有可能成功地编写这些较小的函数。

8.5 将我们的调试技能应用于新问题

在我们掌握新技能和新的工作流程之后,让我们尝试解决一个更具挑战性的调试问题。记得在第六章中我们试图确定教室中给定行中最大的空座位数吗?我们在这里将解决一个类似的问题。

我们不是在寻找空座位,而是假设我们想要计算一排中连续坐下的最多学生人数,因为你试图在考试前更好地分配空间。你也可以想象如果他们想要更多的个人空间或者有流行病时,也可以使用这个方法来更好地分配空间。我们编写了提示和一个基本的 doctest,Copilot 生成了解决方案。代码如下所示。

列表 8.8 第一次尝试计算一排中连续学生的数量
def most_students(classroom):                                 #1
 **'''** 
 **classroom is a list of lists** 
 **Each ' ' is an empty seat** 
 **Each 'S' is a student** 

 **Find the most students seated consecutively in a row** 

 **>>> most_students([['S', ' ', 'S', ' ', 'S', 'S'],\** #2
 **['S', ' ', 'S', 'S', 'S', ' '],\** #2
 **[' ', 'S', ' ', 'S', ' ', ' ']])** #2
 **3** #2
 **'''**
    max_count = 0                          #3
    for row in classroom:                  #3
        count = 0                          #3
        for seat in row:                   #3
            if seat == 'S':                #3
                count += 1                 #3
            else:                          #3
                if count > max_count:      #3
                    max_count = count      #3
                count = 0                  #3
    return max_count                       #3

import doctest                     #4
doctest.testmod(verbose=True)      #4

#1 我们给 Copilot 的提示

#2 第一个测试用例

#3 Copilot 的代码

#4 运行我们添加的 doctest 的代码

由于本章是关于调试的,你可能猜到代码可能没有正确工作。我们在阅读 Copilot 给出的代码时发现了这个错误,但它是一个微妙的错误,我们怀疑可能会被轻易忽略。如果你已经看到了它,做得好,但请假装你没有看到本章的其余部分。如果你还没有看到它,本章的其余部分对你来说将更有价值。

让我们假设我们刚刚写下了这个提示和测试用例。我们阅读了代码,看起来它可能是在跟踪连续最多的学生人数。只要它看到有学生在座位上,它就会增加计数。当座位上没有学生时,它会检查计数是否比之前看到的任何计数都大,并重置计数。看起来它至少是在正确的轨道上。我们包含了一个测试用例,所以运行了代码,测试用例通过了。我们对代码感到相当满意,但知道我们需要做更多的测试用例,特别是那些用来捕捉边缘情况的测试用例(记住边缘情况是不常见的,可能会破坏代码)。

我们知道当我们处理列表时,检查代码在列表的开始和结束处是否正确执行是很好的。为了测试列表的末尾,让我们添加一个测试用例,其中最大的连续学生组包括最后一个座位,然后重新运行代码。以下是我们要添加到文档字符串中的新测试用例:

>>> most_students([['S', ' ', 'S', 'S', 'S', 'S'],\    #1
                  ['S', ' ', 'S', 'S', 'S', ' '],\
                  [' ', 'S', ' ', 'S', ' ', ' ']])
4

#1 最长的连续学生组是 4。

我们再次运行代码,并对测试用例失败感到惊讶。这是它告诉我们的(我们重新格式化输出以提高可读性):

Trying:
    most_students([['S', ' ', 'S', 'S', 'S', 'S'],
                   ['S', ' ', 'S', 'S', 'S', ' '],                
                   [' ', 'S’, ' ', 'S’, ' ', ' ']])
Expecting:
    4
**********************************************************************
File "c:\Copilot\max_consecutive.py", 
line 12, in __main__.most_students

Failed example:
    most_students([['S', ' ', 'S', 'S', 'S', 'S'],
                   ['S', ' ', 'S', 'S', 'S', ' '],                
                   [' ', 'S', ' ', 'S', ' ', ' ']])
Expected:
    4
Got:
    3

这很奇怪——代码看起来工作得很好。这个边缘情况揭示了一个错误。在这个时候,我们想要生成一些关于代码为什么没有正确工作的假设,以帮助指导我们的调试工作。(如果你真的陷入了困境,你可以采取在函数的第一行代码处设置断点并逐步执行的方法,而不是试图创建一个假设。)以下是两个想到的假设:

  • count 的更新跳过了列表中的最后一个元素。

  • max_count 的更新缺少列表中的最后一个元素。

为了简化调试过程,我们移除了通过测试(只是将其放在一边稍后恢复)并只运行失败的测试。以下列表显示了我们在开始调试过程之前的完整代码。

列表 8.9 我们将调试的代码以计算连续学生数
def most_students(classroom): 
 '''
 classroom is a list of lists
 Each ' ' is an empty seat
 Each 'S' is a student

 Find the most students seated consecutively in a row

 >>> most_students([['S', ' ', 'S', 'S', 'S', 'S'],\ #1
 **['S', ' ', 'S', 'S', 'S', ' '],\** #1
 **[' ', 'S', ' ', 'S', ' ', ' ']])** #1
 **4**
 **'''**
    max_count = 0
    for row in classroom:
        count = 0
        for seat in row:
            if seat == 'S':
                count += 1
            else:
                if count > max_count:
                    max_count = count
                count = 0
    return max_count

import doctest
doctest.testmod(verbose=True)

#1 失败的测试用例

我们将从第一个假设开始,即 count 在列表末尾没有正确更新,并在 count 的更新处设置一个断点。图 8.12 显示了调试器启动后第一次暂停的情况。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/8-12.png

图 8.12 调试器在 count 第一次更新前停止

从调试器中,我们可以看到 count 仍然是 0,所以它还没有被更新。我们处于第一个测试用例的第一行,因为 row['S', ' ', 'S', 'S', 'S', 'S']。我们正在查看的座位是 'S',这就是为什么计数在增加。让我们在调试工具栏中点击继续,以查看 count 的下一个更新。点击继续后调试器的状态如图 8.13 所示。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/8-13.png

图 8.13 调试器在 count 第二次更新前停止

从上次更新 count 以来,似乎发生了很多事情,因为 max_count 现在是 1。这一定是在处理空位时发生的,因为 max_count 被设置为 1,而 count 被重置回 0。此时,我们处于行中的第三个座位,那里有一个学生,count 准备更新。我们希望检查 count 是否会随着每个新学生的到来而更新。我们点击了“继续”,count 增加到 1。我们再次点击“继续”,count 增加到 2。我们再次点击“继续”,count 增加到 3。此时,我们处于行中的最后一个学生,我们希望检查 count 是否增加到 4。为了检查这一点,我们点击了一次“单步执行”,count 确实更新到 4。您可以在图 8.14 中看到此时调试器的状态。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/8-14.png

图 8.14 调试器在 count 连续更新四次后立即停止

好吧,此时我们有好消息和坏消息。好消息是 count 正确更新了。坏消息是我们的第一个假设是错误的,我们还没有找到我们的错误。我们可以将断点移动到更新 max_count 的行(第 22 行),然后点击“重启”并重新开始我们的第二个假设的调试过程,但鉴于我们的调试器中 count 现在是 4,让我们继续跟踪代码,确保 max_count 被更新。或者更确切地说,我们知道它不会更新,所以我们要看看为什么。

在点击“单步执行”之前,调试器中已经存在一个线索。这个线索来自于即将执行的下一行代码是 for seat in row。但刚刚看到的那个学生是 row 中的最后一个学生。这意味着这个 for 循环即将结束(这意味着我们不会再次执行循环体,这意味着 max_count 无法更新)。让我们通过点击“单步执行”来查看这是否会发生。调试器的状态如图 8.15 所示。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/8-15.png

图 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 中的代码是修正后的函数(不包括测试或提示)。

列表 8.10 修正后的函数,用于计算连续的学生人数
def most_students(classroom):
    max_count = 0
    for row in classroom:
        count = 0
        for seat in row:
            if seat == 'S':
                count += 1
                if count > max_count:    #1
                    max_count = count    #1
            else:
                count = 0
    return max_count

#1 将对 count 与 max_count 的测试移至更新 count 之后

这段新代码通过了旧代码和原始测试中失败的测试。在添加另一个确保当最长连续学生组出现在行首时代码能够正常工作的测试之后,我们更有信心代码现在正在正确工作。

8.6 使用调试器更好地理解代码

我们怀疑你已经对调试器印象深刻了。我们也是。当学生以传统方式学习编程时,花费大量时间确保学生能够像调试器一样跟踪代码,绘制所有变量的状态,并随着每条新执行行的更新而更新它们。确实,网络上甚至有一个名为 Python Tutor [2] 的免费工具,它可以创建内存状态的图表,比调试器更容易阅读,仅此帮助新程序员学习代码的执行方式。

无论你是否喜欢使用调试器,还是想使用像 Python Tutor 这样的工具,我们都鼓励你玩一玩书中前面章节中编写的一些代码。在我们个人与学习编程的人合作的经验中,逐行遍历程序并观察变量状态的变化可以是一次真正启发性的体验,我们希望你也同样欣赏。

8.7 关于调试的注意事项

从与学生的合作中,我们还看到调试对于新学习者来说可能是一个非常令人沮丧的经历 [3]。在学习如何编程时,每个人都希望他们的代码能够工作,而找到并修复错误是在事情不工作时的耗时。有一些方法可以帮助克服这种挫折。首先,问题分解可以大大帮助你从 Copilot 获取正确的代码,而无需进行大量的调试。其次,记住,每个人的代码有时都会出问题,包括我们的。这只是编程过程中的一个自然部分,也是需要一些练习的部分。最后,始终如一,我们真的意味着始终,测试你写的每一个函数。很多时候,当我们的学生在调试时遇到困难,那通常是因为没有测试每个函数,导致多个函数相互作用产生了错误。当这种情况发生时,找到和修复错误特别困难。调试相互作用的错误如此令人沮丧,以至于避免这种经历是我们两个都虔诚地测试我们写的每一个函数的主要原因。

好消息是,如果你测试你写的每一个函数,并且勤奋地将问题分解成小而可管理的步骤,你不应该经常遇到调试问题。而且,如果你确实遇到了,你将调试一个函数中的错误,这正是地球上几乎每一个程序员都在做的事情。经过一些练习,你会掌握它的。

8.8 练习

  1. 你被提供了一个 Python 函数,该函数本应计算列表中所有偶数的总和。然而,这个函数没有正确工作。当你用列表[1, 2, 3, 4]调用该函数时,它返回4而不是6
def sum_even_numbers(numbers):
    total = 0
    for number in numbers:
        if number % 2 == 0:
            total += number
        else:
            total = 0
    return total

识别sum_even_numbers函数中的错误,并解释如何修复它。修改代码以纠正错误,并使用提供的测试用例验证你的解决方案。

    1. 你被提供了一个 Python 函数,该函数本应找出一个数字列表中的最大值。然而,这个函数没有按预期工作。你的任务是确定为什么这个函数工作不正确,以及你可以如何修复它(提示:考虑输入列表中可能的整数值):
def find_max(numbers): 
    max_number = 0 
    for i in range(0, len(numbers)): 
        if numbers[i] > max_number: 
            max_number = numbers[i] 
   return max_number
    1. 你被提供了一个 Python 函数,该函数本应检查一个字符串是否是有效的密码。一个有效的密码必须满足以下标准:

    2. 它必须至少有 8 个字符长。

    3. 它必须至少包含一个大小写字母。

    4. 它必须至少包含一个小写字母。

    5. 它必须至少包含一个数字。

然而,这个函数没有正确工作。确定这个函数有什么问题,并修复它:

def is_valid_password(password):
    if len(password) <= 8:
        if any(char.isupper() for char in password):
            if any(char.islower() for char in password):
                if any(char.isdigit() for char in password):
                    return True
            return False
        return False
    return False
    1. 你正在编写一个 Python 函数,根据一天中的时间问候用户。该函数接受一个小时(介于 0 到 23 之间的整数)作为输入,并返回一个问候消息(“早上好”、“下午好”或“晚上好”):
def greet_user(hour):
  # This code has an error!
  if hour < 12:
    greeting = "Good morning"
  else:
    greeting = "Good evening"
  return greeting

你正在考虑使用这个提示来请求 Copilot 帮助你修复 bug:“我有一个基于一天中时间的问候用户的函数,但它似乎把中午分类为‘晚上好’。你能帮我修复这个逻辑吗?”这个提示提到了问题,但没有指出具体的错误。看看你是否可以编辑提供的提示,使其更加具体,并引导 Copilot 提出正确的修复方案。

摘要

  • 调试是一个重要的技能,包括在代码中查找错误,然后纠正它们。

  • 打印语句可以是一种了解你的代码中正在发生什么的有效方式。

  • VS Code 调试器是另一种了解你的代码中正在发生什么的方法,它提供了强大的功能来监控变量在代码执行过程中的变化。

  • 一旦发现错误,有几种方法可以帮助 Copilot 为你修复错误,但如果那失败了,你通常可以直接修复代码。

  • 我们设计函数的工作流程现在包括调试,而调试技能将使你更有可能编写你想要的软件。

  • 除了调试之外,VS Code 调试器还可以是一个强大的工具,用于更深入地了解代码的工作方式。

第九章:自动化繁琐的任务

本章涵盖

  • 理解程序员编写工具的原因

  • 确定我们需要编写特定工具的模块

  • 自动化清理带有> > >符号的电子邮件

  • 自动化操作 PDF 文件

  • 自动化在多个图像库中删除重复图片

假设你负责创建 100 份报告,每份报告对应 100 个人中的每一个。也许你是一名教师,需要给每个学生发送一份报告。也许你在人力资源部门工作,需要给每个员工发送年度评估报告。无论你的角色如何,你都有必须创建这些报告的问题,你决定将这些报告作为.pdf 文件准备。你还需要为每份报告准备定制的封面页,这些封面页是由你的同事(一位平面设计师)设计的。

你和你的同事独立工作,最后工作完成了。或者等等,不是那么快。因为现在你必须将每个封面页放在每份报告的开头。

在这个阶段,一个非程序员可能会咬紧牙关开始工作,手动将封面页与第一份报告合并,第二封面页与第二份报告合并,以此类推。这可能会花费数小时。由于不知道还有其他方法,一个非程序员可能会一直努力直到工作完成。

但你现在是一名程序员。而且大多数程序员,包括我们两个,都不会手动进行这样的工作。

在本章中,我们将向你展示如何编写程序来自动化繁琐的任务。章节中的第二个例子将自动化“合并封面页与报告”的情况。但我们还会做其他的事情。收到已被转发多次的电子邮件

它看起来

像这样

这个?

或者,你的家人有几部手机,每部手机都有几百张图片,你只是想将所有图片放在同一个地方,以便你可以归档它们而不会丢失任何东西?在本章中,我们将向你展示如何自动化这类任务。

9.1 为什么程序员要制作工具

程序员常常表达的一种普遍观点是:我们很懒惰。这并不意味着我们不想完成我们的工作。这意味着我们不想做那些重复、无聊、繁琐的工作,因为那是计算机擅长的事情。程序员对这种苦差事有一种类似蜘蛛侠的感觉。假设 Leo 有几百张照片,他想要删除任何重复的照片。他不可能手动做这件事。或者假设 Dan 必须给他的每个学生发送定制的电子邮件。如果学生人数超过几个,他也不可能手动做这件事。一旦程序员开始注意到他们在键盘上重复相同的按键或一遍又一遍地执行相同的步骤,他们就会停下来,制作一个工具来自动化这个过程。

当程序员谈论工具时,他们是在谈论那些能节省他们时间的程序。一个工具通常不是最终目标,编写一个工具本身可能会感觉枯燥乏味,并不光彩。但一旦我们有了工具,我们就可以用它来节省时间。有时,我们可能会用一次工具,完成一项特定的任务,然后就不会再用了。然而,通常情况下,一个工具会反复被证明是有用的,无论是我们按照我们编写的方式使用它,还是通过做一些小的改动。例如,丹完成每一门课程的授课后,他会使用自己编写的程序来整理所有学生的成绩并提交给大学。每次他都会对工具做一些小的改动——比如改变每个作业的权重——但丹就可以使用这个略微修改过的工具来完成工作。

使用 Copilot 的好处是它使制作这些工具变得更加容易。以下是某位软件工程师的解释:

我们都知道工具很重要,有效的工具很难创建,而管理层并不关心或理解对工具的需求……我无法表达现在编程感觉有多么根本性地不同,因为我现在每天可以构建两个质量工具,以满足我每一个想要解决的问题。[1]

9.2 如何使用 Copilot 编写工具

正如我们在第五章讨论模块时所学到的,有时我们需要使用一个模块来帮助我们编写我们想要的程序。一些模块是内置在 Python 中的。例如,在第五章中,我们使用了内置的 zipfile 模块来帮助我们创建一个 .zip 文件。其他模块不是内置的,我们需要先安装它们才能使用。

在编写工具时,我们通常需要处理一些特殊的数据格式(zip 文件、PDF 文件、Microsoft Excel 电子表格、图像)或执行一些特殊的任务(发送电子邮件、与网站交互、移动文件)。对于大多数这些任务,我们都需要使用一个模块。那么,是哪个模块呢?它是内置的还是我们需要安装它?这是我们首先需要得到答案的问题。

幸运的是,我们可以使用 Copilot Chat(或 ChatGPT)来帮助我们开始。作为提醒,我们使用 Copilot Chat 功能是因为它内置在我们的 Visual Studio Code(VS Code)IDE 中,并且因为 Copilot Chat 可以访问我们目前正在编写的代码,因此它可以将其所做的工作纳入其答案中。

计划是与 Copilot 进行对话,以确定我们需要使用哪个模块。一旦我们知道这一点并安装了模块(如果需要的话),我们就可以着手编写我们工具的代码了。我们将像以前一样做:编写函数头和文档字符串,让 Copilot 填写代码。一旦 Copilot 开始编写代码,我们需要遵循与前面章节相同的步骤,包括检查代码正确性、修复错误,甚至可能进行一些问题分解。为了将我们的注意力集中在编写自动化任务的工具上,我们将尽量减少在这些额外任务上花费的时间。

可能可以向 Copilot 或 ChatGPT 请求为我们编写整个工具,甚至不需要将其放入函数中。不过,我们在这里不会这样做,因为我们仍然认为函数的好处是值得的。函数将帮助我们记录代码,以便我们知道它做什么,并且它使我们能够在以后决定,例如,向函数添加额外的参数以改变工具的行为。

9.3 示例 1:清理电子邮件文本

有时候,一封电子邮件被回复和转发多次,变得一团糟,有些行上有许多大于(>)符号和空格。以下是我们所指的样本电子邮件:

嗨,利奥,

丹 – 你的自然语言研究有什么进展吗?

是的!你给我看的那个网站

www.kaggle.com/

非常有用。我在那里找到一个数据集,它收集了

很多

可能对我的研究有用的问答。

谢谢,

假设你想保存这封电子邮件信息以备将来使用。你可能希望清理行首的>和空格符号。你可以开始手动删除它们——毕竟,这封电子邮件并不长——但不要这样做,因为这里你有机会设计一个通用的工具,你可以在需要执行此任务时使用它。无论你的电子邮件有五行、一百行还是一百万行,这都不会有关系:只需使用工具,完成任务即可。

9.3.1 与 Copilot 对话

我们需要让杂乱的电子邮件可供我们的工具使用,以便工具可以清理它。我们可以做到这一点的一种方法是将电子邮件的文本首先复制到剪贴板(使用您操作系统的复制到剪贴板命令,例如 Ctrl-C)。

每个计算机操作系统都有一个剪贴板来存储最后复制的内容。如果你曾经使用过复制粘贴,你就使用过那个剪贴板。我们将使用剪贴板作为文本的临时存储,以便我们可以在剪贴板中编辑文本,然后将其粘贴到所需的位置。

在将乱糟糟的电子邮件内容复制到剪贴板后,我们可以在剪贴板中的文本上运行工具,该工具可以清理电子邮件并将剪贴板内容替换为清理后的版本。为了使工具执行合理操作,我们假设您的剪贴板上有电子邮件文本。工具将删除每行开头的>和空格字符,这样您就可以使用操作系统的粘贴命令(如 Ctrl-V)将新清理的电子邮件粘贴到您想要的位置。在 Python 方面,它需要将剪贴板内容拉入一个字符串(即复制剪贴板的内容),清理它,然后将新的清理文本放回剪贴板(即粘贴到剪贴板),替换之前那里的垃圾邮件版本。

那么,我们如何在 Python 程序中访问剪贴板?这正是我们需要弄清楚的问题,这将是我们与 Copilot 对话的框架。类似于第八章,我们将使用“Human”作为我们输入的提示,使用“GitHub Copilot”作为 Copilot 的响应。您可以使用 Copilot Chat 功能或 ChatGPT 进行类似的对话。我们正在将提示输入到 Copilot Chat 窗口中。

在我们与 Copilot 的对话中,我们将广泛探索,然后确定解决方案。一旦我们通过一个示例掌握了事情,我们就会解释为什么这样做,但在这里我们注明这一点,以便您为这种看似不那么直接的方法做好准备。Copilot 在其响应中提供了大量的示例代码。在我们探索的过程中,您可以随意浏览那些代码,但不要陷入其中。一旦我们完成探索并确定计划,我们再来看代码的更多细节。让我们开始学习吧!

LPHuman 我想写一个 Python 程序,可以访问剪贴板并让我使用剪贴板进行复制和粘贴。我该如何操作?

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/chatGpt.pngGitHub Copilot 您可以使用 pyperclip 模块在 Python 中访问剪贴板。以下是一个示例程序,它允许您使用剪贴板进行复制和粘贴:

*````*```pypython*````*

*`*import pyperclip*`*

*`*# 复制到剪贴板*`*

*`*pyperclip.copy('Hello, world!')*`*

*`*# 从剪贴板粘贴*`*

*`*text = pyperclip.paste()*`*

*`*print(text)*`*

*```py`*```*```py`*

此程序首先导入 pyperclip 模块。然后使用copy()函数将字符串'Hello, world!'复制到剪贴板。最后,使用paste()函数将剪贴板的内容粘贴到“text”变量中,并打印结果。

您可以通过在终端中运行pip install pyperclip来使用 pip 安装 pyperclip 模块。

Copilot 的响应告诉我们,我们可以在 Python 程序中使用 pyperclip 模块与剪贴板进行交互。它提供的示例代码甚至展示了如何从我们的程序中复制内容到剪贴板,并将剪贴板中的内容粘贴到一个变量(text)中。我们取得了很大的进步!

响应的最后一段告诉我们如何安装 pyperclip。啊哈!所以 pyperclip 并不是 Python 默认自带的。虽然我们稍后会学习如何安装模块,但不必担心这一点可能是个好事。所以,让我们继续对话,询问 Copilot 是否有更好的方法。

LPHuman 想知道是否有办法在不安装 Python 模块的情况下完成这个操作?

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/chatGpt.pngGitHub Copilot 是的,你可以使用 Python 自带的 tkinter 模块来访问剪贴板。以下是一个示例程序,它允许你使用剪贴板复制和粘贴,而不需要安装任何额外的模块:

*````*```pypython*````*

*`*import tkinter as tk*`*

*`*# Create a Tkinter window*`*

*`*root = tk.Tk()*`*

*`*# Hide the window*`*

*`*root.withdraw()*`*

*`*# Copy to clipboard*`*

*`*root.clipboard_clear()*`*

*`*root.clipboard_append('Hello, world!')*`*

*`*# Paste from clipboard*`*

*`*text = root.clipboard_get()*`*

*`*print(text)*`*

*```py`*```*```py`*

这个程序首先使用 Tk() 函数创建一个隐藏的 Tkinter 窗口,并使用 withdraw() 方法将其隐藏。然后,它使用 clipboard_clear() 方法清除剪贴板,并使用 clipboard_append() 方法将字符串 'Hello, world!' 添加到剪贴板。最后,它使用 clipboard_get() 方法检索剪贴板的内容并打印结果。

注意,这种方法仅在存在图形环境时才有效,例如 Linux 或 macOS 上的桌面环境,或 Windows 上的 Windows 桌面。如果你在仅终端的环境中运行 Python,你需要使用不同的方法来访问剪贴板。

看起来我们既有所得也有所失。所得是,这个 tkinter 模块是 Python 内置的。所失是,“该方法仅在存在图形环境时才有效。”在我们的 Python 设置中是否有图形环境呢?虽然我们可以尝试代码来找出答案,但这个要求让我们犹豫。毕竟,pyperclip 解决方案没有这个要求,所以我们可能不需要它(无论它具体意味着什么)。让我们再试一次。

LPHuman 还有没有其他方法可以在不安装 Python 模块的情况下完成这个操作?

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/chatGpt.pngGitHub Copilot 是的,你可以使用包含在 Windows 标准库中的 win32clipboard 模块来访问剪贴板。以下是一个示例程序,它允许你使用剪贴板复制和粘贴,而不需要安装任何额外的模块:

*````*```pypython*````*

*`*import win32clipboard*`*

*`*# Copy to clipboard*`*

*`*text = "Hello, world!"*`*

*`*win32clipboard.OpenClipboard()*`*

*`*win32clipboard.EmptyClipboard()*`*

*`*win32clipboard.SetClipboardText(text)*`*

*`*win32clipboard.CloseClipboard()*`*

*`*# Paste from clipboard*`*

*`*win32clipboard.OpenClipboard()*`*

*`*pasted_text = win32clipboard.GetClipboardData()*`*

*`*win32clipboard.CloseClipboard()*`*

*`*print(pasted_text)*`*

*```py`*```*```py`*

这个程序首先导入 win32clipboard 模块。要复制文本到剪贴板,你可以调用 OpenClipboard() 函数来打开剪贴板,然后调用 EmptyClipboard() 来清除任何现有数据,最后调用 SetClipboardText() 来设置剪贴板上的文本。设置文本后,你应该调用 CloseClipboard() 来关闭剪贴板。

要从剪贴板粘贴,你可以调用 OpenClipboard() 来打开剪贴板,然后调用 GetClipboardData() 来从剪贴板获取文本。获取文本后,你应该调用 CloseClipboard() 来关闭剪贴板。在这个例子中,程序将文本 "Hello, world!" 复制到剪贴板,然后粘贴并打印到控制台。

“Windows 上的标准库?”这令人担忧,因为这意味着我们的工具可能在其他操作系统上无法工作。让我们看看情况。

LPHuman 这个在 Linux 或 macOS 上也工作吗?

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/chatGpt.pngGitHub Copilot 不,win32clipboard 模块是针对 Windows 的,在 Linux 或 macOS 上无法工作。在 Linux 上,你可以使用 xclip 命令行工具来访问剪贴板。以下是一个示例程序,允许你在 Linux 上使用剪贴板复制和粘贴:

*````*```pypython*````*

*`*import subprocess*`*

*`*# 复制到剪贴板*`*

*`*text = "Hello, world!"*`*

*`*subprocess.run(['xclip', '-selection', 'clipboard'], input=text.encode('utf-8'))*`*

*`*# 从剪贴板粘贴*`*

*`*p = subprocess.Popen(['xclip', '-selection', 'clipboard', '-o'], stdout=subprocess.PIPE)*`*

*`*pasted_text = p.stdout.read().decode('utf-8')*`*

*`*print(pasted_text)*`*

*```py`*```*```py`*

这个程序使用 subprocess 模块来运行 xclip 命令行工具。要复制文本到剪贴板,你可以调用 subprocess.run() 并传入 xclip 命令,使用 -selection clipboard 选项来指定剪贴板选择,以及 input 参数来传入你想要复制的文本。要从剪贴板粘贴,你可以调用 subprocess.Popen() 并传入 xclip 命令,使用 -selection clipboard -o 选项来从剪贴板读取文本,然后使用 Popen 对象的 stdout 属性读取命令的输出。在这个例子中,程序将文本 "Hello, world!" 复制到剪贴板,然后粘贴并打印到控制台。

在 macOS 上,你可以使用 pbcopypbpaste 命令行工具来访问剪贴板。用法与 xclip 类似。

我们已经得到了一些针对不同操作系统的特定解决方案。我们不喜欢这样!我们希望我们的工具无论在哪个操作系统上都能工作。(即使我们只使用 Windows,也许有一天,我们会将这个工具与其他使用不同操作系统的用户分享。)

我们可以继续我们的对话,但回顾起来,安装那个单一的 pyperclip 模块似乎并不那么糟糕。让我们继续吧。一般来说,在决定如何开始编写工具时探索你的选项是有价值的,与 Copilot 进行对话有助于进行这种探索。

为什么我们应该探索和理解我们的选项

有几个原因说明为什么你不应该仅仅使用 Copilot 聊天告诉你的第一个模块:

  • 它可能不会在所有操作系统上工作。一般来说,我们想要找到可以在多个操作系统上工作的模块,这样工具就可以供你(如果你后来切换操作系统)或与你分享工具的其他人使用。

  • 最终可能发现它并不适用于我们的用例,或者我们可能难以让代码正常工作。如果我们有其他选择,我们可以在投入太多时间到一个可能或不可能最终成功的策略之前,先尝试那些其他选项。

  • 模块经常更新。通常这是为了添加新功能并修复错误,但有时在这个过程中,功能也可能被移除或更改。当一个模块的作者移除一个功能时,这被称为*弃用该功能**。一旦一个功能被弃用,它可能就不再工作,我们需要找到一个替代方案。Copilot 只是偶尔进行训练,而不是持续训练,所以如果 Copilot 在训练后该模块的新版本发布,Copilot 将不会知道这些更新。这可能导致 Copilot 建议依赖于比你所拥有的模块旧版本的代码。我们将在本章后面看到解决这个问题的方法。但如果你遇到麻烦,有其他选项可供探索是有用的。

9.3.2 编写清理电子邮件的工具

我们需要做的第一件事是安装 pyperclip 模块。那么我们该如何安装它呢?好消息是 Python 使得安装新模块变得很容易。前往 VS Code 右下角的终端,并输入以下命令:

pip install pyperclip

注意:对于某些操作系统,你可能需要使用pip3而不是pip。在 Windows 机器上,如果你遵循了第二章中的安装说明,我们建议使用pip。在 Mac 或 Linux 机器上,我们建议使用pip3

当你运行这个命令时,你可能会看到还安装了其他模块。不用担心这个问题:有时模块需要其他模块来执行它们的工作,Python 会自动安装所有这些模块,这样你就不必担心自己安装这些依赖项了。

安装完成后,我们可以导入该模块,然后为我们的函数编写头和文档字符串,该函数将从行首删除>和空格字符。我们在这里导入模块的原因是影响 Copilot 在编写函数代码时使用该模块。以下是我们的提示和 Copilot 给出的代码。

列表 9.1 第一次尝试:清理剪贴板中的电子邮件文本
import pyperclip

def clean_email():
 '''
 The clipboard contains lines of text.
 Clean up the text by removing any > or space
 characters from the beginning of each line.
 Replace the clipboard with the cleaned text.
 '''
    text = pyperclip.paste()                      #1
    lines = text.splitlines()                 #2
    for i in range(len(lines)):                   #3
        lines[i] = lines[i].lstrip(' >')             #4
    text = '        #5

'.join(lines)       #5

    pyperclip.copy(text)       #6

if __name__ == '__main__':            #7
    clean_email()       #8

#1 将剪贴板当前内容放入文本字符串

#2 将字符串拆分为其行列表

#3 遍历每一行

#4 从行的开头移除空格和 > 符号

#5 这看起来有点奇怪。

#6 将清理后的文本复制到剪贴板

#7 注意一个常见的 Python 习惯(见以下讨论)。

#8 调用我们的函数来自动清理剪贴板!

我们可以看到程序正在执行正确的步骤序列:它正在获取剪贴板内容,清理文本,然后将清理后的文本复制回剪贴板。正如我们从与 Copilot 的早期聊天中学到的,我们希望 Copilot 编写使用 pyperclippastecopy 函数的代码,这正是它所做的事情。程序倒数第二行是我们在这本书中之前没有见过的:

if __name__ == '__main__':

实际上,如果你喜欢,可以删除这一行(如果你这样做,也要取消下一行的缩进)。这确保了 clean_email 函数只有在运行你的程序时才会被调用,而不是在将其作为模块导入时。毕竟,如果你确实想将其作为模块导入(作为更大程序的一部分使用),你会在需要该功能时调用 clean_email,而不一定是模块导入时。 (而且,一般来说,每当你想更全面地理解一行代码时,你都可以询问 Copilot!)不幸的是,这段代码不起作用。如果你运行它,你会收到以下错误:

File "C:\repos\book_code\ch9\email_cleanup.py", line 14
    text = '
           ^
SyntaxError: unterminated string literal (detected at line 14)

语法错误意味着我们有一个没有用有效 Python 代码编写的程序。我们现在将修复它!我们有几个选项可以这样做。一个是突出显示你的代码,并要求 Copilot Chat 修复错误。对我们来说,这确实解决了问题。你也可以尝试在与 ChatGPT 的对话中提出:“为我的代码中的错误提出修复建议 <插入你的代码>。” 这是一个有用的提示,当你从 Copilot 得到的代码没有按预期工作时要记住!

Copilot 通过修复有语法错误的行为我们修复了代码。新的代码如下所示。

列表 9.2 第二次尝试:清理剪贴板中的电子邮件文本
import pyperclip

def clean_email():
 '''
 The clipboard contains lines of text.
 Clean up the text by removing any > or space
 characters from the beginning of each line.
 Replace the clipboard with the cleaned text.
 '''
    text = pyperclip.paste()
    lines = text.splitlines()
    for i in range(len(lines)):
        lines[i] = lines[i].lstrip(' >')
    text = '\n'.join(lines)        #1

    pyperclip.copy(text)

if __name__ == '__main__':
    clean_email()

#1 将单独的行重新组合成一个字符串

新的代码行,与之前奇怪的一行代码不同,是

text = '\n'.join(lines)

这行的目的是将所有文本行连接成一个单独的字符串,程序稍后会将其复制到剪贴板。那个 \n 代表什么?它代表代码中换行的开始。join 方法是什么?它接受列表(行)中的所有项并将它们连接成一个单独的字符串。

我们可以通过稍微实验 join 来更详细地理解它是如何工作的。这里是一个使用空字符串而不是 '\n' 字符串的 join 示例:

>>> lines = ['first line', 'second', 'the last line']   #1
>>> print(''.join(lines))           #2
first linesecondthe last line

#1 显示三行的列表

#2 在空字符串上调用 join 方法

注意,有些单词挤在一起。这并不是我们想要的–我们需要在它们之间留点空间。怎么样,让我们再次尝试使用join,这次在字符串中使用空格而不是空字符串:

>>> print(' '.join(lines))
first line second the last line

或者,我们也可以使用'*'

>>> print('*'.join(lines))
first line*second*the last line

这样就解决了我们的单词挤压问题。而且,*s告诉我们每行在哪里结束,但最好实际上保持电子邮件是三行的事实。

在 Python 中,我们需要一种方法来使用换行符或换行符字符,而不是空格或*。我们不能只是按 Enter 键,因为这会将字符串分成两行,这不是有效的 Python 语法。要做到这一点,我们可以使用'\n'

>>> print('\n'.join(lines))
first line
second
the last line

现在我们的工具已经准备好使用了。如果你将一些杂乱的电子邮件文本复制到剪贴板,运行我们的程序,然后粘贴剪贴板,你会看到电子邮件已经被清理。例如,如果我们对之前的样本电子邮件运行它,我们会得到以下清理后的版本:

嗨,利奥,

丹–你在自然语言研究方面有什么进展吗?

是的!你给我看的那个网站

www.kaggle.com/

非常有用。我在那里找到一个数据集,它收集了

很多

可能对我的研究有用的问答。

谢谢,

当然,我们还可以做更多。那封电子邮件中的换行符不太好(“很多”这一行非常短且没有必要),你可能还想清理一下。你可以通过向 Copilot 的提示中添加新要求来开始进行这些改进。我们在这里停下来,因为我们已经完成了电子邮件清理的初步目标,但我们鼓励你继续独立探索更稳健的解决方案。

9.4 示例 2:向 PDF 文件添加封面页

让我们回到本章开头的场景。我们已经编写了 100 份报告,这些报告都是.pdf 格式的。我们的同事为这些报告设计了 100 个封面,这些封面也是.pdf 格式的,我们需要将封面与报告合并,以便每个最终生成的.pdf 文件从封面开始,然后继续是报告。图 9.1 展示了所需的过程。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/9-1.png

图 9.1 创建合并.pdf 文件所需过程的示意图,通过将封面目录中的报告封面与报告目录中的报告合并。注意,报告可能有多页。
PDF 文件(以及 Microsoft Word 和 Excel 文件)不是文本文件

你可能想知道为什么我们不能简单地使用 Python 的readwrite方法来操作.pdf 文件。毕竟,我们在第二章处理.csv 文件时就是这样做的。

最大的区别在于.csv 文件是文本文件,这些文件是未经格式化或特殊命令的人类可读文件。然而,许多其他文件格式不是文本文件。例如,.pdf 文件不是人类可读的,需要由了解.pdf 格式的代码进行处理。同样,Microsoft Word 文件和 Microsoft Excel 文件也是如此:它们不是文本文件,因此我们需要专门的工具来处理它们。

在本书的资源中,在 ch9 文件夹中,您将找到两个子文件夹:报告子文件夹包含 100 个.pdf 格式的报告,封面子文件夹包含 100 个.pdf 格式的封面,每个报告一个。报告命名为 1.pdf、2.pdf、3.pdf 等;相应的封面命名为 cover1.pdf、cover2.pdf、cover3.pdf 等。在本节中,我们将合并这些报告和封面。

打开一些封面.pdf 文件和一些报告.pdf 文件。您可以使用 Adobe Acrobat 或您通常在计算机上打开.pdf 文件的方式来实现。您会注意到封面总是恰好一页,但报告可能有多页。当我们到达代码时,我们应该注意告诉 Copilot 报告可能有多页;否则,Copilot 可能会编写假设报告,就像封面一样,只有一页。

您可能想知道我们是如何创建这 100 个封面和报告的。如果我们一个接一个地手动创建,您会感到失望,对吧?别担心。我们以与自动化合并过程相同的方式自动化了创建过程。

9.4.1 与 Copilot 对话

我们的工具需要找到所有报告和封面,将封面添加到每个报告的前面,并为每个报告生成一个包含封面页和报告页的最终.pdf 文件。我们如何使用 Python 合并.pdf 文件?让我们来看看。

LPHuman 我需要编写一个Python 程序来合并.pdf 文件。我该如何操作?

**https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/chatGpt.pngGitHub Copilot 您可以使用 PyPDF2 库在 Python 中合并 PDF 文件。以下是一个示例代码片段,展示了如何合并两个 PDF 文件:

*````*```pypython*````*

*`*import PyPDF2*`*

*`*# 打开要合并的 PDF 文件*`*

*`*pdf1 = open('file1.pdf', 'rb')*`*

*`*pdf2 = open('file2.pdf', 'rb')*`*

*`*创建一个用于每个文件的 PDF 阅读器对象*`*

*`*reader1 = PyPDF2.PdfFileReader(pdf1)*`*

*`*reader2 = PyPDF2.PdfFileReader(pdf2)*`*

*`*# 创建一个 PDF 写入器对象*`*

*`*writer = PyPDF2.PdfFileWriter()*`*

*`*# 遍历每个 PDF 的页面并将它们添加到 writer 对象中*`*

*`*for i in range(reader1.getNumPages()):*`*

*`*page = reader1.getPage(i)*`*

*`*writer.addPage(page)*`*

*`*for i in range(reader2.getNumPages()):*`*

*`*page = reader2.getPage(i)*`*

*`*writer.addPage(page)*`*

*`*# 将合并后的 PDF 写入文件*`*

*`*output = open('merged.pdf', 'wb')*`*

*`*writer.write(output)*`*

*`*# 关闭输入和输出文件*`*

*`*pdf1.close()*`*

*`*pdf2.close()*`*

*`*output.close()*`*

*```py`*```*```py`*

在这个例子中,我们首先使用open()函数打开要合并的两个 PDF 文件。然后,我们使用 PyPDF2 的PdfFileReader()函数为每个文件创建一个 PDF 读取对象。

接下来,我们使用PdfFileWriter()函数创建一个 PDF 写入对象。然后,我们使用 reader 对象的getNumPages()getPage()方法遍历每个 PDF 文件的页面,并使用addPage()方法将每个页面添加到 writer 对象中。

最后,我们使用 writer 对象的write()方法将合并后的 PDF 写入文件,并使用close()方法关闭所有输入和输出文件。

我们有线索了!这个 PyPDF2 模块似乎能完成我们需要的功能。它看起来提供了PdfFileReader来读取现有的.pdf 文件和PdfFileWriter来创建新的.pdf 文件。我们看到代码使用getPage从现有的.pdf 文件中获取页面,并使用addPage将其写入新的.pdf 文件——非常有前景。Python 中有没有内置的我们可以使用的东西,这样我们就不必担心安装 PyPDF2 了?

LPHuman 有没有一种方法可以在不安装 Python 模块的情况下完成?

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/chatGpt.pngGitHub Copilot——是的,你可以使用 subprocess 模块调用命令行工具,如pdftk来合并 PDF 文件。以下是一个示例代码片段,展示了如何使用pdftk合并两个 PDF 文件:

*````*```pypython*````*

*`*import subprocess*`*

*`*调用 pdftk 合并 PDF 文件*`*

*`*subprocess.call(['pdftk', 'file1.pdf', 'file2.pdf',*`*

*`*'cat', 'output', 'merged.pdf'])**

*```py`*```*```py`*

在这个例子中,我们使用subprocess.call()函数调用pdftk,并使用适当的参数合并两个 PDF 文件file1.pdffile2.pdfcat参数告诉pdftk连接输入文件,而output参数指定输出文件名为merged.pdf

注意,你需要在你的系统上安装pdftk才能使此代码工作。如果你没有安装pdftk,你可以从官方网站下载它:www.pdflabs.com/tools/pdftk-the-pdf-toolkit/

这种方法最终可能很有价值,但并不是一个内置的解决方案。毕竟,最后一段提到了我们不得不安装一些名为pdftk的新软件!

到目前为止,我们可能会选择使用 PyPDF2 的第一个选项。但让我们再次使用 Copilot 来确保我们没有错过任何好的选项。

LPHuman 我应该考虑其他 Python .pdf 库吗?

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/chatGpt.pngGitHub Copilot——是的,还有几个其他 Python 库你可以用来处理 PDF 文件。以下是一些流行的库:

  1. ReportLab——一个用于从头创建 PDF 文档的库,支持文本、图形和图像。

  2. FPDF——一个用于从头创建 PDF 文档的库,支持文本、图形和图像。

  3. PDFMiner——一个用于从 PDF 文件中提取文本和元数据的库。

  4. PyMuPDF——一个用于处理 PDF 文件的库,支持文本、图形和图像。

  5. pdfrw——一个用于读取和写入 PDF 文件的库,支持文本、图形和图像。

每个库都有其自身的优缺点,因此您应该选择最适合您需求的库。如果您只需要合并 PDF 文件,那么 PyPDF2 或 pdftk 可能就足够了。然而,如果您需要以更复杂的方式创建或操作 PDF 文件,那么其他库可能是一个更好的选择。

最后一段告诉我们,如果我们只需要合并.pdf 文件,那么使用 PyPDF2 可能就足够了。但让我们通过了解所有这些工具的优缺点来结束我们的对话。这样,如果我们遇到 PyPDF2 的问题,我们就知道下一步该怎么做。

LPHuman 您可以总结一下这些库的优缺点吗?

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/chatGpt.pngGitHub Copilot 毫无疑问!以下是关于我提到的库的优缺点的总结:

PyPDF2——一个用于处理 PDF 文件的库,支持文本、图形和图像。

优点:

  1. 对于简单任务(如合并 PDF 文件)来说易于使用。

  2. 提供良好的文档和示例。

  3. 支持广泛的 PDF 功能。

缺点:

  1. 对高级 PDF 功能(如表单和注释)的支持有限。

  2. 大型 PDF 文件可能运行缓慢。

ReportLab——一个用于从头创建 PDF 文档的库,支持文本、图形和图像。

优点:

  1. 创建复杂的 PDF 文档非常强大和灵活。

  2. 支持广泛的 PDF 功能。

  3. 提供良好的文档和示例。

缺点:

  1. 对于初学者来说,学习曲线陡峭。

  2. 大型 PDF 文件可能运行缓慢。

FPDF——一个用于从头创建 PDF 文档的库,支持文本、图形和图像。

优点:

  1. 轻量级且易于使用。

  2. 提供良好的文档和示例。

  3. 支持广泛的 PDF 功能。

缺点:

  1. 对高级 PDF 功能(如表单和注释)的支持有限。

  2. 大型 PDF 文件可能运行缓慢。

PDFMiner——一个用于从 PDF 文件中提取文本和元数据的库。

优点:

  1. 从 PDF 文件中提取文本和元数据非常强大和灵活。

  2. 支持广泛的 PDF 功能。

  3. 提供良好的文档和示例。

缺点:

  1. 对于初学者来说,学习曲线陡峭。

  2. 创建或操作 PDF 文件的支持有限。

PyMuPDF——一个用于处理 PDF 文件的库,支持文本、图形和图像。

优点:

  1. 处理 PDF 文件非常快速和高效。

  2. 支持广泛的 PDF 功能。

  3. 提供良好的文档和示例。

缺点:

  1. 对于初学者来说,学习曲线陡峭。

  2. 创建或操作 PDF 文件的支持有限。

pdfrw——一个用于读取和写入 PDF 文件的库,支持文本、图形和图像。

优点:

  1. 读取和写入 PDF 文件非常强大和灵活。

  2. 支持广泛的 PDF 功能。

  3. 提供良好的文档和示例。

缺点:

  1. 对于初学者来说学习曲线陡峭。

  2. 对于大型 PDF 文件可能运行缓慢。

不要被“对于初学者来说学习曲线陡峭”的警告吓倒。如果 Copilot 在该库上进行了训练,那么你仍然可能成功。

根据我们在这里学到的知识,我们可能会消除 PDFMiner,因为它显然对创建或操作 PDF 文件的支持“有限”,但我们仍会保留所有其他选项,以防万一我们需要它们。目前,我们将继续使用 PyPDF2 并看看效果如何。

9.4.2 编写工具

前往安装 PyPDF2 模块。注意大小写:模块名为 PyPDF2,而不是 pypdf2、pyPDF2 或任何其他大小写形式。

在我们之前的例子中,我们编写了一个用于清理剪贴板上的电子邮件的函数。我们不需要向该函数传递任何信息,因此该函数没有参数。在这里,我们的函数将需要三个参数:包含报告的目录名称、包含封面的目录名称以及我们想要写入最终(合并).pdf 文件的目录名称。

在我们的文档字符串中,我们需要解释每个目录的作用,以及目录中文件名的模式。毕竟,Copilot 需要知道报告是如何与封面页匹配的。

尝试 PyPDF2

我们将使用 PyPDF2 Python 库来尝试完成这项工作。回顾 Copilot Chat 告诉我们的关于 PyPDF2 库的信息。它给出的示例代码使用了 PdfFileReader 来读取现有的 .pdf 文件,并使用 PdfFileWriter 来创建新的 .pdf 文件。它还使用了 getPageaddPage 来操作页面。注意 Copilot 生成的代码中的这些内容——它们是 Copilot 正在生成正确代码的明显迹象。以下是我们的提示和 Copilot 给出的代码。

列表 9.3 添加封面到 .pdf 文档的函数
import PyPDF2

def add_covers(report_dir, cover_dir, final_dir):
 '''
 report_dir is a directory of reports named 1.pdf, 2.pdf, and so on.
 These files may be one page or more than one page.

 cover_dir is a directory of covers, with one cover per report.
 The filenames in this directory are cover1.pdf, cover2.pdf, and 
 so on. Each of these files is one page.

 Add the cover to the beginning of each report,
 and store all resulting pdfs in final_dir.
 '''
    report_files = os.listdir(report_dir)      #1
    for report_file in report_files:                  #2
        report = open(os.path.join(report_dir, report_file), 'rb')
        report_reader = PyPDF2.PdfFileReader(report)         #3
        report_writer = PyPDF2.PdfFileWriter()                         #4
        for page_num in range(report_reader.numPages):              #5
            report_writer.addPage(  \       #6
            report_reader.getPage(page_num))     #6
        cover = open(os.path.join(cover_dir,                       #7
                     'cover' + report_file), 'rb')       #6
        cover_reader = PyPDF2.PdfFileReader(cover)            #8
        report_writer.insertPage(  \                                #9
                      cover_reader.getPage(0), 0)         #6
        result = open(os.path.join(final_dir, report_file), 'wb') #6
        report_writer.write(result)   #10
        report.close() #6
        cover.close() #6
        result.close() #6

if __name__ == '__main__': #6
    add_covers('reports', 'covers', 'final')   #11

#1 获取 .pdf 报告的列表

#2 遍历 .pdf 报告

#3 我们可以使用 report_reader 来读取报告的页面。

#4 我们可以使用 report_writer 将页面写入新的 .pdf 文件

#5 遍历报告的每一页

#6 将页面添加到我们新的 .pdf 文件中

#7 打开与该报告关联的封面

#8 我们可以使用 cover_reader 来读取封面。

#9 将封面添加到我们新的 .pdf 文件的开头

#10 保存我们新的 .pdf 文件

#11 调用我们的函数来完成所有合并操作!

注意自动化程序

我们编写的用于合并 .pdf 文件的程序可以非常快速地处理数百或数千个文件。如果它们操作不正确,它们很容易导致文件损坏或丢失。每次我们使用 open 并将 'w''wb' 作为第二个参数时,这意味着我们正在覆盖一个文件。

让我们关注列表 9.3 中的这一行:

result = open(os.path.join(final_dir, report_file), 'wb')

它正在使用open函数打开一个文件。具体来说,它正在打开final_dir目录中的当前report_file文件。open函数这里的第二个参数'wb'意味着我们想要打开文件以便写入(这就是'w'),而我们正在写入的文件是一个二进制文件(这就是'b'),而不是文本文件。如果文件不存在,那么我们包含的'w'将导致文件被创建。这不是危险的部分。危险的部分是当文件已经存在时会发生什么。在这种情况下,'w'会清除其内容,给我们一个空文件,我们可以开始写入。现在,如果我们的程序运行正确,并且只在我们final_dir中这样做,那么我们就没问题。但这是我们让程序运行之前需要仔细验证的。

我们强烈建议您首先在一个您不关心的文件小目录上测试。此外,我们建议将使用 'w''wb' 打开文件的代码行更改为打印一条无害的输出消息,这样您就可以确切地看到哪些文件将被覆盖或创建。例如,在我们的程序中,我们需要注释掉这两行:

result = open(os.path.join(final_dir, report_file), 'wb')
report_writer.write(result)

相反,我们将使用print打印出我们本应创建或覆盖的文件:

print('Will write', os.path.join(final_dir, report_file))

然后,当您运行程序时,您将看到程序打算写入的文件名。如果输出看起来不错——也就是说,程序正在对您想要的文件进行操作——那么您就可以取消注释实际执行工作的代码。

练习谨慎,并且始终备份您的重要文件!

列表 9.3 中的程序最后一行假设报告目录被称为reports,封面页目录被称为covers,最终.pdf 文件应该放入的目录被称为final

现在,创建final目录。它应该与您的reportscovers目录一起存在。

代码的整体结构对我们来说很有希望:它获取了一份.pdf 报告的列表,然后,对于每一个,它将那些页面与封面页合并。它使用for循环遍历报告的页面,这是好的,因为它可以通过这种方式抓取所有页面。相比之下,它没有在封面.pdf 文件上使用for循环,这同样很好,因为我们知道封面页只有一页。

然而,它给出的第一行代码看起来像是在一个名为os的模块中使用了一个名为listdir的函数。还有其他一些行也使用了这个模块。我们需要导入这个os模块吗?实际上,我们需要!我们可以通过运行代码来证明这一点。如果您运行代码,您将得到一个错误:

Traceback (most recent call last):
  File "merge_pdfs.py", …
    add_covers('reports', 'covers', 'final')
  File " merge_pdfs.py",  …
    report_files = os.listdir(report_dir)
                   ^^
NameError: name 'os' is not defined

我们需要在程序开始处添加import os来修复这个问题。更新的代码在下面的列表中。

列表 9.4 改进的添加封面到.pdf 文档的功能
import os          #1
**import PyPDF2**

**def add_covers(report_dir, cover_dir, final_dir):**
 **'''**
 **report_dir is a directory of reports named 1.pdf, 2.pdf, and so on.**
 **These files may be one page or more than one page.**

 **cover_dir is a directory of covers, with one cover per report.**
 **The filenames in this directory are cover1.pdf, cover2.pdf, and so on.**
 **Each of these files is one page.**

 **Add the cover to the beginning of each report,**
 **and store all resulting pdfs in final_dir.**
 **'''**
    report_files = os.listdir(report_dir)
    for report_file in report_files:
        report = open(os.path.join(report_dir, report_file), 'rb')
        report_reader = PyPDF2.PdfFileReader(report)
        report_writer = PyPDF2.PdfFileWriter()
        for page_num in range(report_reader.numPages):
            report_writer.addPage(report_reader.getPage(page_num))
        cover = open(os.path.join(cover_dir, 'cover' + report_file), 'rb')
        cover_reader = PyPDF2.PdfFileReader(cover)
        report_writer.insertPage(cover_reader.getPage(0), 0)
        result = open(os.path.join(final_dir, report_file), 'wb')
        report_writer.write(result)
        report.close()
        cover.close()
        result.close()

if __name__ == '__main__':
    add_covers('reports', 'covers', 'final')

#1 我们之前缺少这个导入。

尽管如此,我们还没有走出困境。我们的更新程序仍然无法工作。当我们运行程序时,我们得到了以下错误:

Traceback (most recent call last):
  File "merge_pdfs.py", line 34, in <module>
    add_covers('reports', 'covers', 'final')
  File "merge_pdfs.py", line 20, in add_covers
    report_reader = PyPDF2.PdfFileReader(report)     #1
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "...\PyPDF2\_reader.py", line 1974, in __init__
    deprecation_with_replacement("PdfFileReader", "PdfReader", "3.0.0")
  File "...\PyPDF2\_utils.py", line 369, in deprecation_with_replacement
    deprecation(DEPR_MSG_HAPPENED.format(old_name, removed_in, new_name))
  File "...\PyPDF2\_utils.py", line 351, in deprecation
    raise DeprecationError(msg)
PyPDF2.errors.DeprecationError: PdfFileReader is  #2
deprecated and was removed in PyPDF2 3.0.0\. Use   #2
PdfReader instead.                                #2

#1 导致错误的代码行

#2 我们不能再使用 PdfFileReader 了——它已经消失了!

我们遇到了一个问题,Copilot 认为,“嘿,让我们使用PdfFileReader,因为我被训练过这是 PyPDF2 的一部分”,但是在 Copilot 被训练和我们现在写作的时间之间,PyPDF2 的维护者已经移除了PdfFileReader,并用其他东西(根据错误信息的最后一行,是PdfReader)替换了它。这个差异可能在你阅读这本书的时候已经被修复了,但我们想假装它仍然有问题,这样我们就可以教你如果将来这种情况发生在你身上应该怎么做。到目前为止,我们有三种选择:

  • 安装 PyPDF2 的早期版本。 错误信息的最后两行告诉我们,我们需要从 PyPDF2 中获取的函数PdfFileReader在 PyPDF2 3.0.0 版本中被移除了。因此,如果我们安装一个低于 3.0.0 版本的 PyPDF2,我们应该能够恢复我们的函数。一般来说,安装库的早期版本是不推荐的,因为这些版本可能存在安全漏洞,这些漏洞在较新版本中已经被修复。此外,旧版本中可能存在一些后来被修复的 bug。值得谷歌一下最近库中发生了什么变化,以确定使用旧版本是否安全。在这种情况下,我们已经完成了这项作业,并看到使用 PyPDF2 的旧版本没有明显的风险。

  • 自己修复代码,使用错误信息中的建议。 也就是说,我们会将PdfFileReader替换为PdfReader,然后再次运行程序。在这种情况下,我们会被告知其他弃用的情况,我们需要按照相同的过程修复它们。PyPDF2 的作者在错误信息中告诉我们该怎么做是非常好的。为了练习,你可能喜欢通过这个,按照错误信息中提出的每个更新进行操作。我们希望所有的错误信息都这么有用,但这种情况并不总是如此。有时,一个函数会被移除,而不给我们任何回旋的余地。在这种情况下,考虑我们的下一个选项可能更容易。

  • 使用不同的库。 之前,我们向 Copilot 询问了其他可能的.pdf Python 库,我们收到了很多建议。如果这里的头两个选项不满意,我们可以尝试其中之一。

我们将展示如何使用第一个选项(使用 PyPDF2 的早期版本)和第三个选项(使用完全不同的库)来解决问题,并让我们的代码运行起来。

使用 PyPDF2 的早期版本

当使用pip install来安装 Python 库时,默认情况下,我们会得到库的最新版本。这通常是我们想要的——最新和最好的,但也可以明确请求库的旧版本。

在这里,我们需要 PyPDF2 的一个低于 3.0.0 版本的版本。而不是使用 pip 的标准用法,

pip install PyPDF2

我们可以使用

pip install "PyPDF2 < 3.0.0"

< 3.0.0是我们用来请求小于 3.0.0 版本的最新的库版本。该命令应该产生如下所示的输出:

Collecting PyPDF2<3.0.0
  Installing collected packages: PyPDF2
  Attempting uninstall: PyPDF2
    Found existing installation: PyPDF2 3.0.1     #1
    Uninstalling PyPDF2-3.0.1:                  #2
      Successfully uninstalled PyPDF2-3.0.1
Successfully installed PyPDF2-2.12.1          #3

#1 我们有一个现有的版本,版本号大于等于 3.0.0。

#2 那个版本正在被移除 …

#3 … 正在安装版本 2.12.1。

现在我们已经安装了 PyPDF2 的较旧版本。到这一点,你应该能够运行我们的程序。这可能需要几秒钟,然后工作就会完成。你可以检查你的最终目录,看看是否有新创建的.pdf 文件。打开几个文件,以确保我们的程序已经正确操作:每个.pdf 文件都应该从封面页开始,并包含报告的所有页面(如果报告有多页,不仅仅是第一页)。

想想如果手动操作会有多麻烦,我们已经让计算机帮我们完成了这项工作。此外,下次我们在工作中需要做同样的事情(为更多的人制作更多报告)时,我们已经有一个工具可以快速帮助我们完成。

使用不同的.pdf 库

现在我们尝试使用另一个库(Copilot 在我们关于.pdf 库的初始对话中提到的一个库)通过 PyPDF2 来解决我们的问题。

我们不会详细向您介绍我们如何决定尝试下一个库的细节,但我们会概述我们的旅程亮点。我们首先询问 Copilot Chat 如何使用 ReportLab Python 模块来合并 PDF 文件,但这又让我们回到了使用 PyPDF2。对于 FPDF,我们也得到了同样的回应:我们被告知 FPDF 不能合并 PDF 文件,但 PyPDF2 可以。然后我们转向询问 PyMuPDF,这次对话是有成效的:

LPHuman 我如何使用 Python PyMuPDF 库来合并.pdf 文件?

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/chatGpt.pngGitHub Copilot 您可以使用 PyMuPDF 库中的 fitz 模块来合并 PDF 文件。以下是一个示例代码片段,展示了如何使用 PyMuPDF 合并两个 PDF 文件:

*````*```pypython*````*

*`*import fitz*`*

*`*合并两个 PDF 文件*`*

*`*pdf_files = ['file1.pdf', 'file2.pdf']*`*

*`*pdf_writer = fitz.Document()*`*

*`*for pdf_file in pdf_files:*`*

*`*pdf_reader = fitz.open(pdf_file)*`*

*`*for page in pdf_reader:*`*

*`*pdf_writer.insert_pdf(page)*`*

*`*pdf_writer.save('merged.pdf')*`*

*`*pdf_writer.close()*`*

*```py`*```*```py`*

这段代码首先创建了一个要合并的 PDF 文件列表(file1.pdffile2.pdf)。然后使用fitz创建一个新的Document对象,代表合并后的 PDF。它遍历列表中的每个 PDF 文件,使用fitz打开它,并使用insert_pdf()方法将 PDF 文件的每一页插入到pdf_writer对象中。最后,使用save()方法将合并后的 PDF 保存到名为merged.pdf的新文件中。

你可以使用 PyMuPDF 修改此代码,以向合并的 PDF 添加任何其他内容。只需创建一个新的 Page 对象,将内容添加到页面,然后使用 insert_page() 方法将页面插入到 pdf_writer 对象中。

此处提供的示例代码使用 insert_pdf 将每个 .pdf 文件添加到合并的 .pdf 文件中。我们应该留意我们使用 insert_pdf 的代码。这个库叫做 PyMuPDF,所以我们需要使用以下命令来安装它:

pip install PyMuPDF

但然后我们必须使用 import fitz?为什么使用 import fitz 而不是 import PyMuPDF?显然,这个库的设计者更喜欢 fitz 而不是 PyMuPDF,所以我们将使用它。这确实很少见,但偶尔我们会使用一个名称来安装库,另一个名称来导入它!

当你导入时遇到 NameError,

如果 Python 给你一个关于你试图导入的库的 NameError,那么很可能你没有成功导入那个库。可能是库实际上没有安装,或者你需要使用不同的名称来导入它。如果你发现你安装了库但似乎无法导入它,请使用 Copilot Chat 进行对话。

现在,我们准备生成一些代码。以下列表显示了我们所使用的提示和 Copilot 给出的代码。

列表 9.5 用于向 .pdf 文档添加封面的改进函数
import os
import fitz 

def add_covers(report_dir, cover_dir, final_dir):
 '''
 report_dir is a directory of reports named 1.pdf, 2.pdf, and so on.
 These files may be one page or more than one page.

 cover_dir is a directory of covers, with one cover per report.
 The filenames in this directory are cover1.pdf, cover2.pdf, and so on.
 Each of these files is one page.

 Add the cover to the beginning of each report,
 and store all resulting pdfs in final_dir.
 '''
    if not os.path.exists(final_dir):          #1
        os.mkdir(final_dir)                         #2
    for report in os.listdir(report_dir):             #3
        report_path = os.path.join(report_dir, report)
        cover_path = os.path.join(cover_dir, f'cover{report}')
        final_path = os.path.join(final_dir, report)
        cover_doc = fitz.open(cover_path)                   #4
        report_doc = fitz.open(report_path)       #5
        final_doc = fitz.open()                #6
        final_doc.insert_pdf(cover_doc)       #7
        final_doc.insert_pdf(report_doc)       #8
        final_doc.save(final_path)            #9

if __name__ == '__main__':
    add_covers('reports', 'covers', 'final')

#1 如果 final_dir 目录不存在, . . .

#2 . . . 创建 final_dir 目录。

#3 遍历 .pdf 报告

#4 用于读取封面

#5 用于读取报告

#6 用作我们的新输出文件

#7 将封面添加到我们的新 .pdf 文件中

#8 将报告添加到我们的新 .pdf 文件中

#9 保存我们的新 .pdf 文件

这个程序恰好以一些代码开始,这些代码会在输出目录不存在的情况下创建它。之后,步骤与我们在之前使用 PyPDF2 的程序中使用的步骤相似,尽管每个程序调用的函数不同。(这是有道理的,因为 PyPDF2 和 PyMuPDF 是不同的库,具有不同的功能和函数。)在两种情况下,我们都在使用 listdir 来获取报告 .pdf 文件名列表。在 for 循环中,我们遍历这些报告;循环中的代码负责创建一个新的 .pdf 文件,其中包含封面后跟报告。在我们的 PyPDF2 代码中,有一个嵌套的 for 循环,我们需要遍历报告的所有页面。在我们的当前程序中,我们不需要这样做,因为 Copilot 使用了 insert_pdf 函数,该函数一次(而不是逐页)将 .pdf 文件插入到另一个 .pdf 文件中。无论你选择安装较旧的库还是选择使用不同的库,我们都解决了问题,并自动化了原本可能是一项令人不愉快的繁琐任务。

注意,我们已经稍微修改了上一章中描述的工作流程,以考虑到处理可能帮助你完成任务的不同 Python 模块。图 9.2 提供了一个修改后的工作流程。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/9-2.png

图 9.2 为处理不同 Python 模块而添加到我们的工作流程中的内容

9.5 示例 3:合并手机照片库

现在假设你在手机上拍了大量照片。你的伴侣(或兄弟姐妹、父母或孩子)也在他们的手机上拍了大量照片。你们每个人都有数百或数千张照片!有时你会给伴侣发送照片,他们也会给你发送照片,这样你和伴侣就拥有了一些但不是全部的照片。

你这样生活了一段时间,但说实话,这变得越来越乱。一半的时候你想找一张照片,却找不到,因为那是你的伴侣在他们手机上拍的照片,他们没有发给你。而且,你开始到处都有很多重复的照片。

然后,你有了这样一个想法:“如果我们把我的手机上的所有照片和你的手机上的所有照片都合并在一起,创建一个包含所有照片的合并库!那么我们就会有一个地方可以找到所有的照片!”记住,你们两个的手机可能都有数百张照片,所以手动做这件事是不可能的。我们将自动化这个过程!

为了更精确地指定我们的任务,我们将说我们有两个图片目录(将每个目录想象成手机的存储内容),我们希望将它们合并到一个新的目录中。图片的常见文件格式是.png 文件,所以我们将在这里处理这些文件。你的实际手机可能使用.jpg 文件而不是.png 文件,但不用担心。如果你喜欢,你可以将我们在这里做的事情适应到那种图片文件格式(或任何其他图片文件格式)。

在本书的资源中,在 ch9 目录下,你可以找到两个图片子目录。这些子目录被命名为 pictures1 和 pictures2。你可以想象 pictures1 包含你手机上的照片(98 张照片)和 pictures2 包含你伴侣手机上的照片(112 张照片)。我们将把这两个手机目录合并到一个新的目录中。

以与你在电脑上打开图片或照片相同的方式打开一些.png 文件。我们生成的图片只是随机形状,但我们在这里编写的程序将适用于图片中的任何内容。

一开始,我们就说过同一张照片可能出现在两部手机上,所以我们已经在图片中生成了一些重复的文件。(我们总共有 210 个图片文件,但其中 10 个是重复的,所以只有 200 张独特的图片。)例如,在 pictures1 目录中有一个名为 1566.png 的文件,在 pictures2 目录中有一个名为 2471.png 的文件。这两个文件是相同的,当我们从两部手机生成文件目录时,我们只想保留其中一个。这里棘手的是,尽管它们的文件名不同,但这些图片实际上是相同的。

如果两个文件名相同,这难道意味着图片也相同吗?例如,请注意,每个目录,pictures1 和 pictures2,都有一个名为 9595.png 的文件。你可能认为文件名相同意味着里面的图片也会相同。但不是这样,如果你打开这些图片,你会发现它们是不同的!这种情况在现实生活中也可能发生:你和你的伴侣可能拍了不同的图片,而且不管有多遥远,手机为这些图片选择的文件名恰好相同。

如果我们不小心,我们可能会将 pictures1 中的 9595.png 复制到我们的新目录中,然后稍后通过从 pictures2 中复制 9595.png 到我们的新目录来覆盖它。我们需要确保当我们复制文件时,我们不会覆盖可能已经存在的不同图片,而这些图片具有相同的文件名。我们在图 9.3 中提供了一个这个过程示例。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/9-3.png

图 9.3 将 pictures1 和 pictures2 文件夹中的照片合并到 pictures–combined 文件夹中并管理删除重复项的示例
我们正在处理目录,而不是实际的手机

我们在这里假设你已经在电脑上有了两个图片目录。这些目录可能来自任何地方,我们在这里所做的一切都与手机无关。

但如果你真的想用手机上的图片来完成这个任务,你需要首先将这些图片上传到电脑上。你可以使用 ChatGPT 来进行这个讨论!由于这些细节与不同的手机有关,并且超出了我们想要使用 Copilot 自动化的范围,所以我们在这里不会进一步讨论。

9.5.1 与 Copilot 对话

我们的工具将从 pictures1 和 pictures2 中创建一个图片的合并目录,但不会包含重复的图片。如果我们合并的原始图片目录中有相同的图片多次,我们只想保留其中一张图片的副本在我们的新目录中。

到目前为止,我们可能想知道至少两件事:我们如何使用 Python 复制文件,以及我们如何知道两个文件是否是同一张图片?让我们先询问 Copilot Chat 我们如何复制文件:

LPHuman 我需要编写一个 Python 程序来复制文件。我该如何做?

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/chatGpt.pngGitHub Copilot 你可以使用 Python 中的 shutil 模块来复制文件。以下是一个示例:

*````*```pypython*````*

*`*import shutil*`*

*`*# Replace the source and destination paths with your own*`*

*`*source = '/path/to/source/file'*`*

*`*destination = '/path/to/destination/file'*`*

*`*# Copy the file*`*

*`*shutil.copy(source, destination)*`*

*```py`*```*```py`*

这将把文件从源路径复制到目标路径。如果目标文件已存在,它将被覆盖。

很好。我们可以使用那个 shutil 模块来复制文件;我们需要记住导入这个模块。Copilot 给出的最后一句是一个重要的警告。我们应该小心避免覆盖已经存在的文件。接下来,让我们确定这个 shutil 模块是否是 Python 内置的。

LPHuman 这个 shutil 模块是 Python 内置的吗?

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/chatGpt.pngGitHub Copilot 是的,shutil 模块是 Python 的一个内置模块,所以你不需要安装任何额外的包来使用它。

你可能会想知道,为什么我们专注于在新的目录中复制文件而不是将它们从原始目录移动到新目录。原因是我们想保持原始目录不变,以防我们有一个 bug 并且未能将所有文件都复制到新目录中。如果新目录中发生任何问题,那没关系,因为我们的原始目录仍然完好无损。

这就结束了我们需要了解的关于复制文件的知识。现在让我们继续学习如何确定两个文件是否是同一张图片。

如我们之前所说,仅使用文件名来确定图片是否相同是不够的。那么我们该如何做呢?

LPHuman 使用 Python,我该如何确定.png 文件是否相同?

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/chatGpt.pngGitHub Copilot 你可以使用 Python 中的 filecmp 模块来比较两个文件并检查它们是否相同。这里有一个比较两个.png 文件的代码片段示例:

*````*```pypython*````*

*`*import filecmp*`*

*`*# Replace these file paths with the actual file paths of the .png files*`*

*`*# you want to compare*`*

*`*file1 = '/path/to/file1.png'*`*

*`*file2 = '/path/to/file2.png'*`*

*`*if filecmp.cmp(file1, file2):*`*

*`*print('The files are the same.')*`*

*`*else:*`*

*`*print('The files are different.')*`*

*```py`*```*```py`*

这段代码将比较两个文件的 内容,如果它们相同则返回True,如果不同则返回False

作为一个小插曲,Copilot 的最后一句是错误的。那段代码在任何地方都没有返回TrueFalse。就像 Copilot 可以在我们的 Python 文件中为我们生成错误的代码一样,Copilot Chat 也可以产生错误的解释。

我们的提示并没有精确说明我们需要比较的文件数量。我们是想比较两个文件吗?还是几百个文件?我们的提示没有说。Copilot 告诉我们如果想要比较两个文件应该怎么做,这是合理的。但这并不是我们需要的。我们需要能够在几百个文件中确定哪些文件是其他文件的副本。这样,我们就可以检测到重复文件,并且不会将其复制到新目录中。让我们再试一次。

LPHuman 如果我有一百多个.png 文件,并且我想找到重复的文件,你给我的那种方法仍然有效吗?

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/chatGpt.pngGitHub Copilot 如果您有数百个.png 文件,并且想要查找重复项,您可以使用 os 模块遍历目录中的所有文件,并将每个文件与目录中的每个其他文件进行比较。以下是一个示例代码片段,展示了如何做到这一点:

*````*```pypython*````*

*`*import os*`*

*`*import filecmp*`*

*`*# 将此替换为实际的目录路径,其中包含您的.png*`*

*`*# 文件位置*`*

*`*directory = '/path/to/directory'*`*

*`*# 遍历目录中的所有文件*`*

*`*for filename1 in os.listdir(directory):*`*

*`*if filename1.endswith('.png'):*`*

*`*for filename2 in os.listdir(directory):*`*

*`*if filename2.endswith('.png') and filename1 != filename2:*`*

*`*file1 = os.path.join(directory, filename1)*`*

*`*file2 = os.path.join(directory, filename2)*`*

*`*if filecmp.cmp(file1, file2):*`*

*`*print(f'{filename1} and {filename2} are duplicates.')*`*

*```py`*```*```py`*

此代码将遍历目录中的所有文件,并将目录中的每个.png文件与每个其他.png文件进行比较。如果两个文件相同,它将打印一条消息,指出它们是重复的。请注意,对于包含大量文件的目录,这种方法可能很慢。

外层for循环遍历目录中的每个文件。我们如何知道当前文件是否有重复项呢?嗯,我们可以遍历目录中的其他文件,并将它们与当前文件进行比较。这就是内层for循环的作用:它使用filecmp.cmp将当前文件(file1)与每个其他文件(file2)进行比较。

在我们的提示中,我们没有提到我们关心跨多个目录查找重复项,所以 Copilot 在这里关注的是单个目录。如果这种差异成为障碍,我们可以使我们的提示更加精确。

Copilot 在这里使用了两个其他模块,os 和 filecmp。我们可以询问 Copilot 这些是否是内置的 Python 模块,但我们将节省一点时间,在这里直接告诉您它们是内置的。

现在,我们希望您关注 Copilot 的最后一句:“请注意,这种方法对于包含大量文件的目录来说可能很慢。”“慢”有多慢?“多”有多少?我们不知道。

您可能会想要求 Copilot 提供一个更好的解决方案,一个对于“对于包含大量文件的目录来说可能很慢”的解决方案。但许多程序员不会这样做。在我们尝试我们的(未优化的、显然慢的)方法之前,优化我们的解决方案通常是一个错误,原因有两个。首先,也许我们的“慢”程序最终足够快!我们不妨试试。其次,更优化的程序通常是更复杂的程序,它们可能更难正确实现。这并不总是这种情况,但它可能是。而且,如果我们的未优化程序完成了工作,我们甚至不必担心更优化的版本。

现在,如果我们的程序真的太慢,或者你发现自己反复使用这个程序,那么继续与 Copilot 合作以获得更快的解决方案可能值得额外的投资。不过,目前来说,我们做得很好。

9.5.2 自顶向下设计

这个任务比我们之前两个任务要复杂一些。一方面,我们需要小心不要覆盖我们新目录中已经存在的文件。另一方面,我们需要确定首先需要复制哪些文件(记住我们只想复制那些在新目录中不匹配的文件)。这与我们刚刚完成的.pdf 合并任务形成对比,在那个任务中我们没有这些额外的担忧。

为了达到这个目的,我们将在这里使用自顶向下设计和问题分解。不用担心,这不会是一个像我们在第七章中做的完全自顶向下的设计示例。我们这里的任务比第七章中的拼写建议任务要小得多。我们只需进行一点自顶向下的设计,这将帮助 Copilot 为我们提供我们想要的结果。

我们的顶级函数将负责解决我们的整体任务:将 pictures1 和 pictures2 目录中的所有独特图片放入目标目录。在第三章,我们学习了我们应该尽可能使函数通用,以便使它们更有用或更易于推广到其他任务。在这里,我们一直在考虑合并两个图片目录。但为什么不是 3、5 或 50 个目录呢?谁在乎我们有多少个目录;我们应该能够合并我们想要的任意多个目录。

因此,我们不会设计我们的顶级函数以接受两个字符串(目录名)作为参数,我们将让函数接受一个字符串列表。这样,我们就可以用它来处理我们想要的任意多个图片目录。而且,我们仍然可以轻松地用它来处理两个图片目录——我们只需传递一个包含两个目录名称的列表。

我们将命名我们的顶级函数为make_copies。我们需要两个参数:我们刚才讨论的目录名称列表,以及我们想要所有文件都放入的目标目录的名称。

这个函数将要做什么?它将遍历目录列表中的每个目录,然后,对于每个目录,它将遍历每个文件。对于每个文件,我们需要确定是否复制它,如果需要复制,则执行实际的复制操作。

确定是否复制文件,然后可能复制它,这是一个可以从make_copies中分离出来的子任务。我们将为这个子任务命名函数为make_copy。我们的make_copy函数将接受两个参数:文件的名称和目标目录。如果文件与目标目录中的任何文件都不相同,那么该函数将把文件复制到目标目录中。

假设我们想要将名为 9595.png 的文件从一个图片目录复制到我们的目标目录,但该文件已经在目标目录中存在。我们不希望覆盖已经存在的文件,因此我们需要想出一个新的文件名。我们可能会尝试在文件名中的.png 部分之前添加一个 (下划线)字符。这将给我们 9595.png。这个文件可能不在目标目录中,但如果它确实存在,我们可以尝试 9595__.png,9595___.png,等等,直到我们找到一个不存在的文件名。

生成一个唯一的文件名是我们可以从make_copy函数中分离出来的任务。我们将称它为get_good_filename。它将接受一个文件名作为参数,并返回一个不存在的文件名版本。

有了这些,我们的自顶向下设计就完成了。图 9.4 描绘了我们的工作作为一个树(至少是树的树干),显示了哪个函数被哪个其他函数调用。

https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/9-4.png

图 9.4 图像合并的自顶向下设计。最顶层(最左侧)的函数是make_copies,它的子函数是make_copy,而make_copy的子函数是get_good_filename

9.5.3 编写工具

这次我们没有需要安装的模块。我们知道从我们的 Copilot 对话中,我们将使用内置的 shutil 模块来复制文件。我们还将使用内置的 filecmp 模块来比较文件,以及内置的 os 模块来获取目录中的文件列表。因此,我们将在 Python 程序的顶部导入这三个模块。

正如第七章所述,我们将从函数树的底部开始解决问题,逐步向上工作。我们这样做是为了当 Copilot 为父函数编写代码时,可以调用我们已编写的函数。对于每个函数,我们提供def行和文档字符串,然后 Copilot 编写代码。我们还提供了一些注释来解释代码的工作原理。

再次查看图 9.4,我们看到我们需要实现的第一项功能是get_good_filename。现在让我们在下面的列表中完成这个功能。

列表 9.6 为我们的图片合并任务编写的get_good_filename函数
import shutil
import filecmp
import os

def get_good_filename(fname):
 '''
 fname is the name of a png file.

 While the file fname exists, add an _ character
 right before the .png part of the filename;
 e.g. 9595.png becomes 9595_.png.

 Return the resulting filename.
 '''
    while os.path.exists(fname):           #1
        fname = fname.replace('.png', '_.png')      #2
    return fname          #3

#1 当文件名存在时…

#2 …在.png 之前插入一个 _,通过将.png 替换为 _.png。

#3 返回我们现在知道不存在的文件名

下一个我们需要编写的函数是make_copy。这个函数将文件复制到目标目录,但前提是文件与我们之前复制的文件不相同。我们希望 Copilot 在其代码中完成以下几件事情:

  • 使用os.listdir获取目标目录中的文件列表。

  • 使用filecmp.cmp确定两个文件是否相同。

  • 使用shutil.copy复制文件,如果没有找到相同的文件。

  • 调用我们刚刚编写的get_good_filename函数。

下面的列表显示了我们的提示和 Copilot 提供的代码。请注意,代码正在做我们希望它做的事情。

列表 9.7 make_copy函数,用于我们的图片合并任务
def make_copy(fname, target_dir):
 '''
 fname is a filename like pictures1/1262.png.
 target_dir is the name of a directory. #2
 #2
 Compare the file fname to all files in target_dir. #2
 If fname is not identical to any file in #2
 target_dir, copy it to target_dir #2
 ''' #2
    for target_fname in os.listdir(target_dir):      #1
        if filecmp.cmp(fname, os.path.join(  \    #2
                   target_dir, target_fname)):   
            return                                    #3
    shutil.copy(fname, get_good_filename(      #4
            os.path.join(target_dir,  \        #4
                 os.path.basename(fname))))   #4

#1 遍历目标目录中的文件

#2 如果文件与目标目录中的某个文件相同,……

#3 ……从函数中返回而没有复制文件。

#4 否则,复制文件并使用一个尚未存在的良好文件名。

只剩下最后一个函数,那就是我们的顶级make_copies函数。对于我们的每个图片目录中的每个文件,我们期望代码调用make_copy来复制文件(如果需要的话),如下面的列表所示。

列表 9.8 make_copies函数,用于我们的图片合并任务
def make_copies(dirs, target_dir):
 '''
 dirs is a list of directory names.
 target_dir is the name of a directory.

 Check each file in the directories and compare it to all files 
 in target_dir. If a file is not identical to any file in 
 target_dir, copy it to target_dir
 '''
    for dir in dirs:                         #1
        for fname in os.listdir(dir):              #2
            make_copy(os.path.join(dir, fname),  \   #3
                      target_dir)                    #3

make_copies(['pictures1', 'pictures2'],  #4
             'pictures_combined')        #3

#1 遍历我们的图片目录

#2 遍历当前图片目录中的文件

#3 如果需要,将当前文件复制到目标目录

#4 在我们的两个图片目录和给定的目标目录上运行我们的程序

Copilot 在make_copies函数下面的最后一行代码假设我们的目标目录将被命名为 pictures_combined。现在创建该目录,以便它位于你的图片 1 和图片 2 目录旁边。

正如我们在本章前面处理.pdf 文件时讨论的那样,你首先在你不关心的样本目录上测试程序是很重要的。你的样本目录中应该只有几个文件,这样你可以手动确定程序是否按预期工作。你还应该包括重要的边缘情况,例如每个目录中都有相同的文件名。

一旦你有了样本目录,你应该创建一个“无害”的程序版本,该程序简单地输出消息而不是实际复制文件。对于我们的程序,你将更改make_copy中的行,使用print而不是shutil.copy

在仔细检查结果后,如果输出看起来不错,那么你应该只在你的真实目录上运行真正的程序。记住,我们的程序是在复制(而不是移动)文件,所以即使在我们的真实目录中,如果出现问题,有很大可能性问题出在我们的新目录中,而不是我们真正关心的原始目录中。

我们假设你现在已经准备好在 pictures1 和 pictures2 目录上运行程序。一旦运行,你可以检查你的 pictures_combined 目录以查看结果。你应该看到该目录有 200 个文件,这正好是我们两个图片目录中独特的图片数量。我们是否正确处理了在两个图片目录中都存在相同文件名但图片不同的情况?是的,你可以看到我们有名为 9595.png 和 9595_.png 的文件,因此我们没有覆盖彼此。

哦,你的程序在你的电脑上运行了多久?最多几秒钟,对吧?结果是,Copilot 的警告“这种方法对于包含许多文件的大型目录可能会很慢”对我们来说并不重要。

现在,我们都知道人们通常在手机上有成千上万的照片,而不是几百张。如果您在两个真实的手机图片库上运行这个程序,您还需要确定它是否在可接受的时间内完成。您可以运行程序,让它运行一分钟左右,或者您愿意等待多久。为了好玩,我们还测试了我们的程序在总共 10,000 个小图像文件上的运行情况(比我们在本章中使用的图片 1 和图片 2 目录中的 210 张图片更现实的场景),我们发现它只用了 1 分钟就完成了。在某个时候,我们的程序可能会变得太慢而无法实用,那时您就需要使用 Copilot Chat 进行进一步的研究,以得到一个更高效的程序。

在本章中,我们成功地自动化了三个繁琐的任务:清理电子邮件、为数百个.pdf 文件添加封面,以及将多个图片库合并为一个。在每种情况下,方法都是相同的:使用 Copilot Chat 确定要使用哪个模块,然后遵循我们在整本书中磨练的方法,让 Copilot 编写所需的代码。

记住,无论何时您发现自己重复执行相同的任务,尝试使用 Copilot 和 Python 来自动化它都是值得的。除了本章中展示的之外,还有很多有用的 Python 模块可以做到这一点。例如,有用于操作图像、处理 Microsoft Excel 或 Microsoft Word 文件、发送电子邮件、从网站抓取数据等模块。如果是一项繁琐的任务,那么很可能有人已经编写了一个 Python 模块来帮助完成这项任务,并且 Copilot 可以帮助您有效地使用该模块。

9.6 练习

  1. 您已经有一个工具可以清理电子邮件文本,通过从每行的开头删除任何>或空格字符。您将采取哪些步骤来增强这个工具,使其也能删除过短的行(例如,少于五个字符的行,不包括空格)?

  2. 您正在编写一个 Python 程序来清理您存储在电脑上的大量图片,您决定使用 Pillow 库(Python 图像库[PIL]的一个分支)。在安装了 Pillow 的最新版本后,您运行了您的程序,但遇到了以下错误:

Traceback (most recent call last):
  File "image_cleanup.py", line 4, in <module>
    resized_image = image.resize((new_width, new_height), Image.ANTIALIAS)
                                                          ^^^^^^^^^^^^^^^
AttributeError: module 'PIL.Image' has no attribute 'ANTIALIAS'

您可以采取哪些步骤来解决这个问题?

    1. 您已经得到了一个名为 sales_data.xlsx 的 Excel 文件,其中包含不同产品的月度销售数据。您的任务是编写一个 Python 程序,读取销售数据,计算每个产品的总销售额,并将结果写入一个名为 total_sales.xlsx 的新 Excel 文件。sales_data.xlsx 文件有每个月份(1 月、2 月等)的列。

您的程序应该执行以下操作:

  1. sales_data.xlsx读取数据。

  2. 计算所有月份中每个产品的总销售额。

  3. 将产品名称和它们的总销售额写入total_sales.xlsx

提示:对输入文件做出合理的假设,你可能需要导入库来帮助你处理 .xlsx 文件。如果你没有 Excel 或 OpenOffice 来读取/写入 .xlsx 文件,请随意使用 .csv 文件来完成这项任务。

    1. 每天从不同来源寻找新闻文章来阅读可能会很繁琐。你的任务是编写一个 Python 爬虫,从新闻网站上提取并显示最新文章的标题和 URL。你需要 beautifulsoup4 和 requests 模块。

摘要

  • 程序员经常制作工具来自动化繁琐的任务。

  • 通常需要使用 Python 模块来帮助我们编写工具。

  • 我们可以使用 Copilot Chat 来确定我们应该使用哪些 Python 模块。

  • 与 Copilot 对话以了解可能对我们可用的各种 Python 模块的优缺点是有帮助的。

  • 有 Python 模块可以用于处理剪贴板、处理 .pdf 文件和其他文件格式、复制文件等。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐