Skip to content

Latest commit

 

History

History
1562 lines (1111 loc) · 77.4 KB

File metadata and controls

1562 lines (1111 loc) · 77.4 KB

一、Python 基础知识

概述

本章回顾了将在未来讨论中使用的基本 Python 数据结构和工具。这些概念将使我们能够刷新关于 Python 最基本和最重要特性的记忆,同时为后面章节中的高级主题做好准备。

到本章结束时,您将能够使用控制流方法来设计 Python 程序和初始化常见的 Python 数据结构,以及操作它们的内容。您将巩固对 Python 算法设计中函数和递归的理解。您还将能够促进 Python 程序的调试、测试和版本控制。最后,在本章末尾的活动中,您将创建一个数独解算器。

导言

近年来,Python 在普及和使用方面有了前所未有的增长,特别是在数学方面,这是本章的主要主题。然而,在我们深入研究高等数学主题之前,我们需要巩固我们对语言基础的理解。

本章将对 Python 的一般概念进行复习;所涵盖的主题将使您在本书后面的讨论中处于最佳位置。具体来说,我们将回顾一般编程中的基本概念,如条件和循环,以及特定于 Python 的数据结构,如列表和字典。我们还将讨论函数和算法设计过程,这是任何包含数学相关程序的中型或大型 Python 项目的重要部分。所有这些都将通过实践练习和活动来完成。

到本章结束时,您将能够很好地处理本书后面几章中更复杂、有趣的问题。

控制流程方法

控制流是一个通用术语,表示可以重定向程序执行的任何编程语法。控制流方法通常允许程序在其执行和计算中是动态的:根据程序或其输入的当前状态,该程序的执行及其输出将动态变化。

if 语句

在任何编程语言中,最常见的控制流形式是条件语句或if语句。if语句用于检查有关程序当前状态的特定条件,并根据结果(条件是真还是假),程序将执行不同的指令集。

在 Python 中,if语句的语法如下:

if [condition to check]:
    [instruction set to execute if condition is true]

考虑到 Python 的可读性,您可能已经猜到了条件是如何工作的:当给定程序的执行达到条件并检查if语句中的条件时,如果条件为真,则将执行语句中的缩进指令集*;否则,程序将跳过这些指令并继续。*

if语句中,我们可以检查复合条件,它是多个单独条件的组合。例如,使用and关键字,以下if块在其两个条件都满足时执行:

if [condition 1] and [condition 2]:
    [instruction set]

与此相反,我们可以在复合条件中使用or关键字,如果关键字左侧或右侧的条件为 true,则该关键字将显示为正(true)。还可以使用多个and/or关键字继续扩展复合条件,以实现嵌套在多个级别上的条件。

当一个条件不满足时,我们可能希望我们的程序执行一组不同的指令。为了实现这个逻辑,我们可以使用elifelse语句,它们应该紧跟在if语句之后。如果不满足if语句中的条件,我们的程序将继续并评估elif语句中的后续条件;如果不满足任何条件,else块内的任何代码都将被执行。Python 中的if...elif...else块的形式如下:

if [condition 1]:
    [instruction set 1]
elif [condition 2]:
    [instruction set 2]
...
elif [condition n]:
    [instruction set n]
else:
    [instruction set n + 1]

当我们的程序需要检查一组可能性时,这种控制流方法非常有价值。根据给定时刻哪种可能性为真,程序应执行相应的指令。

练习 1.01:条件整除

在数学中,对变量及其内容的分析非常常见,最常见的分析之一是整数的整除性。在这个练习中,我们将使用 HORT T0 语句来考虑给定数字的可除性为 5, 6 或 7。

执行以下步骤以实现此目的:

  1. 创建一个新的 Jupyter 笔记本,声明一个名为x的变量,其值为任意整数,如下代码所示:

    x = 130
  2. After that declaration, write an if statement to check whether x is divisible by 5 or not. The corresponding code block should print out a statement indicating whether the condition has been met:

    if x % 5 == 0:
        print('x is divisible by 5')

    这里,%是 Python 中的模运算符;当我们将var变量除以数字n时,var % n表达式返回余数。

  3. 在同一代码单元中,编写两条elif语句,分别检查x是否可被 6 和 7 整除。适当的print语句应置于其相应的条件下:

    elif x % 6 == 0:
        print('x is divisible by 6')
    elif x % 7 == 0:
        print('x is divisible by 7')
  4. 编写最后的else语句,打印出一条消息,说明x不能被 5、6 或 7 整除(在同一代码单元中):

    else:
        print('x is not divisible by 5, 6, or 7')
  5. 每次使用分配给x的不同值运行程序,以测试我们的条件逻辑。下面的输出是一个例子,其中x被分配了值104832

    x is divisible by 6
  6. Now, instead of printing out a message about the divisibility of x, we would like to write that message to a text file. Specifically, we want to create a file named output.txt that will contain the same message that we printed out previously.

    为此,我们可以使用with关键字和open()函数与文本文件交互。请注意,open()函数包含两个参数:要写入的文件名,在我们的例子中是output.txt,以及w(用于写入),它指定我们要写入文件,而不是从文件中读取内容:

    if x % 5 == 0:
        with open('output.txt', 'w') as f:
            f.write('x is divisible by 5')
    elif x % 6 == 0:
        with open('output.txt', 'w') as f:
            f.write('x is divisible by 6')
    elif x % 7 == 0:
        with open('output.txt', 'w') as f:
            f.write('x is divisible by 7')
    else:
        with open('output.txt', 'w') as f:
            f.write('x is not divisible by 5, 6, or 7')
  7. 检查输出文本文件中的消息是否正确。如果x变量仍然保持104832的值,则文本文件应包含以下内容:

    x is divisible by 6

在本练习中,我们使用条件语句编写了一个程序,使用%运算符确定给定数字被 6、3 和 2 整除的可能性。我们还了解了如何用 Python 将内容写入文本文件。在下一节中,我们将开始讨论 Python 中的循环。

笔记

当任一条件为真时,elif 块中的代码行按顺序执行,并从顺序中断。这意味着,当 x 被指定值 30 时,一旦满足x%5==0,则不检查x%6==0

要访问此特定部分的源代码,请参考https://packt.live/3dNflxO.

您也可以在在线运行此示例 https://packt.live/2AsqO8w

回路

另一种广泛使用的控制流方法是使用循环。它们用于在指定范围内或满足条件时重复执行同一组指令。Python 中有两种类型的循环:while循环和for循环。让我们详细了解每一个。

while 循环

while循环就像if语句一样,检查指定的条件,以确定给定程序的执行是否应继续循环。例如,考虑下面的代码:

>>> x = 0
>>> while x < 3:
...     print(x)
...     x += 1
0
1
2

在前面的代码中,在x被值0初始化后,使用while循环依次打印出变量的值,并在每次迭代中递增相同的变量。可以想象,当这个程序执行时,012将被打印出来,当x达到3时,while循环中指定的条件不再满足,因此循环结束。

注意,x += 1命令对应于x = x + 1,它在循环的每次迭代中增加x的值。如果我们删除这个命令,那么每次都会得到一个无限循环打印0

for 循环

另一方面,for循环通常用于迭代特定的值序列。使用 Python 中的range函数,以下代码生成与我们之前完全相同的输出:

>>> for x in range(3):
...     print(x)
0
1
2

in关键字是 Python 中任何for循环的关键:当使用它时,它前面的变量将在迭代器中赋值,我们希望依次循环。在前一种情况下,在for循环的每次迭代中,x变量被赋予range(3)迭代器内的值,依次为012

Pythonfor循环中也可以使用其他类型的迭代器,而不是range()。下表简要总结了for循环中使用的一些最常见的迭代器。如果您不熟悉此表中包含的数据结构,请不要担心;我们将在本章后面介绍这些概念:

Figure 1.1: List of datasets and their examples

图 1.1:数据集列表及其示例

也可以将多个循环嵌套在另一个循环中。当给定程序的执行在循环中时,我们可以使用break关键字退出当前循环并继续执行。

练习 1.02:猜数字游戏

在本练习中,我们将把循环的知识用于实践,并编写一个简单的猜测游戏。在程序开始时随机选择 0 到 100 之间的目标整数。然后,程序将接收用户输入,作为对这个数字的猜测。作为响应,如果猜测大于实际目标,程序将打印出一条消息,称为Lower,如果相反的消息为真,则打印出Higher。当用户正确猜测时,程序应终止。

执行以下步骤以完成此练习:

  1. In the first cell of a new Jupyter notebook, import the random module in Python and use its randint function to generate random numbers:

    import random
    true_value = random.randint(0, 100)

    每次调用randint()函数时,它都会在传递给它的两个数字之间生成一个随机整数;在我们的例子中,将生成一个介于 0 和 100 之间的整数。

    虽然在本练习的其余部分不需要它们,但如果您对 random 模块提供的其他功能感兴趣,您可以在上查看其官方文档 https://docs.python.org/3/library/random.html

    笔记

    程序的其余部分也应放在当前代码单元中。

  2. 使用 Python 中的input()函数接收用户的输入,并将返回值赋给变量(guess,在下面的代码中)。该值将被解释为用户对目标的猜测:

    guess = input('Enter your guess: ')
  3. Convert the user input into an integer using the int() function and check it against the true target. Print out appropriate messages for all possible cases of the comparison:

    guess = int(guess)
    if guess == true_value:
        print('Congratulations! You guessed correctly.')
    elif guess > true_value:
        print('Lower.')  # user guessed too high
    else:
        print('Higher.')  # user guessed too low

    笔记

    下面代码段中的#符号表示代码注释。注释被添加到代码中,以帮助解释特定的逻辑位。

  4. 对于我们当前的代码,int()函数将抛出一个错误,如果其输入无法转换为整数(例如,当输入为字符串时),则会使整个程序崩溃。因此,我们需要实现try...except块中的代码来处理用户输入非数字值的情况:

    try:
        if guess == true_value:
            print('Congratulations! You guessed correctly.')
        elif guess > true_value:
            print('Lower.')  # user guessed too high
        else:
            print('Higher.')  # user guessed too low
    # when the input is invalid
    except ValueError:
        print('Please enter a valid number.')
  5. As of now, the user can only guess exactly once before the program terminates. To implement the feature that would allow the user to repeatedly guess until they find the target, we will wrap the logic we have developed so far in a while loop, which will break if and only if the user guesses correctly (implemented by a while True loop with the break keyword placed appropriately).

    完整的程序应类似于以下代码:

    import random
    true_value = random.randint(0, 100)
    while True:
        guess = input('Enter your guess: ')
        try:
            guess = int(guess)
            if guess == true_value:
                print('Congratulations! You guessed correctly.')
                break
            elif guess > true_value:
                print('Lower.')  # user guessed too high
            else:
                print('Higher.')  # user guessed too low
        # when the input is invalid
        except ValueError:
            print('Please enter a valid number.')
  6. 尝试通过执行代码单元重新运行程序,并测试不同的输入选项,以确保程序能够很好地处理其指令,以及处理无效输入的情况。例如,当目标编号被随机选择为 13 时,程序可能产生的输出如下:

    Enter your guess: 50
    Lower.
    Enter your guess: 25
    Lower.
    Enter your guess: 13
    Congratulations! You guessed correctly.

在本练习中,我们练习了在数字猜测游戏中使用while循环,以巩固我们对编程中循环用法的理解。此外,还向您介绍了在用户输入中读取的方法和 Python 中的random模块。

笔记

要访问此特定部分的源代码,请参考https://packt.live/2BYK6CR.

您也可以在在线运行此示例 https://packt.live/2CVFbTu

接下来,我们将开始考虑常见的 Python 数据结构。

数据结构

数据结构是一种变量类型,表示您可能希望在程序中创建、存储和操作的不同形式的信息。与控制流方法一起,数据结构是任何编程语言的另一个基本构建块。在本节中,我们将从字符串开始介绍 Python 中一些最常见的数据结构。

字符串是通常用于表示文本信息(例如消息)的字符序列。Python 字符串由单引号或双引号内的任何给定文本数据表示。例如,在下面的代码片段中,ab变量包含相同的信息:

a = 'Hello, world!'
b = "Hello, world!"

由于字符串在 Python 中被粗略地视为序列,因此可以对该数据结构应用常见的序列相关操作。特别是,我们可以将两个或多个字符串连接在一起以创建一个长时间运行的字符串,我们可以使用for循环遍历字符串,并且可以使用索引和切片来访问单个字符和子字符串。以下代码演示了这些操作的效果:

>>> a = 'Hello, '
>>> b = 'world!'
>>> print(a + b)
Hello, world!
>>> for char in a:
...     print(char)
H
e
l
l
o
,
 # a blank character printed here, the last character in string a
>>> print(a[2])
l
>>> print(a[1: 4]) 
ell

Python3.6 中添加的最重要的特性之一是 f-strings,这是一种在 Python 中格式化字符串的语法。因为我们使用的是 Python3.7,所以我们可以利用这个特性。当我们希望将给定变量的值插入预定义字符串时,使用字符串格式。在 f-strings 之前,还有两个您可能熟悉的格式选项:%-格式和str.format()。这两种方法没有太多的细节,它们有一些不需要的特性,因此开发了 f 字符串来解决这些问题。

f 字符串的语法是用花括号、{}定义的。例如,我们可以使用 f 字符串组合变量的打印值,如下所示:

>>> a = 42
>>> print(f'The value of a is {a}.')
The value of a is 42.

当一个变量放在 f 字符串的花括号内时,它的__str__()表示将用于最终打印输出。这意味着您可以在使用 Python 对象时通过覆盖和自定义 dunder 方法__str__()来获得 f 字符串的进一步灵活性。

字符串的常用数字格式选项,例如指定十进制或日期时间格式后的位数,可以使用冒号在 f 字符串中完成,如下所示:

>>> from math import pi
>>> print(f'Pi, rounded to three decimal places, is {pi:.3f}.')
Pi, rounded to three decimal places, is 3.142.
>>> from datetime import datetime
>>> print(f'Current time is {datetime.now():%H:%M}.')
Current time is 21:39.

f 字符串的另一个优点是,它们比其他两种字符串格式化方法渲染和处理速度更快。接下来,让我们讨论 Python 列表。

列表

列表可以说是 Python 中最常用的数据结构。它是 Python 自己的 Java 或 C/C++数组版本。列表是可以按顺序访问或迭代的元素序列。与 Java 数组不同,Python 列表中的元素不必具有相同的数据结构,如下所示:

>>> a = [1, 'a', (2, 3)]  # a list containing a number, a string, and a tuple

笔记

我们将在下一节中进一步讨论元组。

如前所述,列表中的元素可以在for循环中以类似于字符串中字符的方式进行迭代。列表也可以按照与字符串相同的方式进行索引和切片:

>>> a = [1, 'a', (2, 3), 2]
>>> a[2]
(2, 3)
>>> a[1: 3]
['a', (2, 3)]

向 Python 列表中添加新元素有两种方法:append()在列表末尾插入一个新元素,而列表串联只是将两个或多个字符串连接在一起,如下所示:

>>> a = [1, 'a', (2, 3)]
>>> a.append(3)
>>> a
[1, 'a', (2, 3), 3]
>>> b = [2, 5, 'b']
>>> a + b
[1, 'a', (2, 3), 3, 2, 5, 'b']

要从列表中删除一个元素,可以使用pop()方法,该方法获取要删除的元素的索引。

使 Python 列表独一无二的操作之一是列表理解:一种 Python 语法,使用置于方括号内的for循环有效地初始化列表。列表理解通常用于将操作应用于现有列表以创建新列表。例如,假设我们有一个列表变量a,其中包含一些整数:

>>> a = [1, 4, 2, 9, 10, 3]

现在,我们想创建一个新的列表,b,它的元素是a中元素的两倍。我们可能会将b初始化为空列表,然后迭代循环a并将适当的值附加到b。但是,通过列表理解,我们可以通过更优雅的语法实现相同的结果:

>>> b = [2 * element for element in a]
>>> b
[2, 8, 4, 18, 20, 6]

此外,我们甚至可以在列表理解中组合条件,以在创建 Python 列表的过程中实现复杂的逻辑。例如,要创建一个包含a中两个奇数元素的列表,我们可以执行以下操作:

>>> c = [2 * element for element in a if element % 2 == 1]
>>> c
[2, 18, 6]

另一个经常与 list 形成对比的 Python 数据结构是 tuple,我们将在下一节中讨论它。但是,在继续之前,让我们先来练习一个新概念:多维列表/数组。

多维数组,也称为表或矩阵(有时是张量),是数学和机器学习领域的常见对象。鉴于 Python 列表中的元素可以是任何 Python 对象,我们可以使用列表中的列表对跨越多个维度的数组进行建模。具体地说,假设在一个总体 Python 列表中,我们有三个子列表,每个子列表中有三个元素。该对象可以看作是一个二维的 3x3 表格。通常,我们可以使用嵌套在其他列表n次中的 Python 列表对n维数组建模。

练习 1.03:多维列表

在本练习中,我们将熟悉多维列表的概念以及遍历它们的过程。我们的目标是编写逻辑命令,动态显示 2D 列表的内容。

执行以下步骤以完成此练习:

  1. Create a new Jupyter notebook and declare a variable named a in a code cell, as follows:

    a = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]

    此变量表示一个 3 x 3 2D 表,列表中的各个子列表表示行。

  2. 在一个新的代码单元中,通过循环列表a中的元素来遍历行(暂时不要运行该单元):

    for row in a:
  3. 在这个for循环的每次迭代中,a中的一个子列表被分配给一个名为row的变量。然后,我们可以通过索引单个行来访问 2D 表中的单个单元格。下面的for循环将打印出每个子列表中的第一个元素,或者换句话说,表中每行第一个单元格中的数字(147):

    for row in a:
        print(row[0])
  4. In a new code cell, print out the values of all the cells in table a by having a nested for loop, whose inner loop will iterate through the sublists in a:

    for row in a:
        for element in row:
            print(element)

    这应该打印出从 1 到 9 的数字,每一个都在一个单独的行中。

  5. Finally, in a new cell, we need to print out the diagonal elements of this table in a nicely formatted message. To do this, we can have an indexing variable — i, in our case — loop from 0 to 2 to access the diagonal elements of the table:

    for i in range(3):
        print(a[i][i])

    您的输出应该是 1、5 和 9,每一个都在单独的行中。

    笔记

    这是因为表/矩阵中对角线元素的行索引和列索引相等。

  6. In a new cell, change the preceding print statements using f-strings to format our printed output:

    for i in range(3):
        print(f'The {i + 1}-th diagonal element is: {a[i][i]}')

    这将产生以下输出:

    The 1-th diagonal element is: 1
    The 2-th diagonal element is: 5
    The 3-th diagonal element is: 9

在本练习中,我们结合了所学的循环、索引和 f 字符串格式,创建了一个在 2D 列表中动态迭代的程序。

笔记

要访问此特定部分的源代码,请参考https://packt.live/3dRP8OA.

您也可以在在线运行此示例 https://packt.live/3gpg4al

接下来,我们将继续讨论其他 Python 数据结构。

元组

用括号而不是方括号声明,Python 元组仍然是不同元素的序列,类似于列表(尽管在赋值语句中可以省略括号)。这两种数据结构之间的主要区别在于,元组在 Python 中是不可变的对象,这意味着它们在初始化后不能以任何方式进行变异或更改,如下所示:

>>> a = (1, 2)
>>> a[0] = 3  # trying to change the first element
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment
>>> a.append(2)  # trying to add another element
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
AttributeError: 'tuple' object has no attribute 'append'

鉴于元组和列表之间的这一关键差异,我们可以相应地利用这些数据结构:当我们希望元素序列因任何原因(例如,为了确保逻辑完整性函数)是不可变的时,可以使用元组;如果我们允许序列在初始化后被更改,它可以被声明为一个列表。

接下来,我们将讨论数学计算中的一种常见数据结构:集合。

如果您已经熟悉数学概念,那么 Python 集的定义基本上是相同的:Python 集是无序元素的集合。一个集合可以用花括号初始化,一个新元素可以用add()方法添加到集合中,如下所示:

>>> a = {1, 2, 3}
>>> a.add(4)
>>> a
{1, 2, 3, 4}

因为集合是 Python 元素的集合,或者换句话说,是迭代器,所以它的元素仍然可以使用for循环进行迭代。但是,根据其定义,不能保证这些元素将按照在集合中初始化或添加到集合中的相同顺序进行迭代。

此外,将集合中已存在的元素添加到该集合时,该语句将无效:

>>> a
{1, 2, 3, 4}
>>> a.add(3)
>>> a
{1, 2, 3, 4}

取两个给定集合的并集或交集是最常见的集合操作,可分别通过 Python 中的union()intersection()方法实现:

>>> a = {1, 2, 3, 4}
>>> b = {2, 5, 6}
>>> a.union(b)
{1, 2, 3, 4, 5, 6}
>>> a.intersection(b)
{2}

最后,要从集合中删除给定元素,我们可以使用discard()方法或remove()方法。两者都从集合中删除传递给它们的项。但是,如果集合中不存在该项,则前者不会对集合进行变异,而后者将引发错误。就像元组和列表一样,您可以在程序中选择使用这两种方法之一来实现特定的逻辑,具体取决于您的目标。

接下来,我们将在本节讨论的最后一个 Python 数据结构是字典。

字典

Python 字典相当于 Java 中的哈希映射,在 Java 中,我们可以指定键-值对关系,并对键执行查找以获得其相应的值。我们可以在 Python 中声明一个字典,方法是以key: value的形式列出键值对,在花括号中用逗号分隔。

例如,一个包含学生姓名的示例词典,对应于他们在课堂上的最终分数,可能如下所示:

>>> score_dict = {'Alice': 90, 'Bob': 85, 'Carol': 86}
>>> score_dict
{'Alice': 90, 'Bob': 85, 'Carol': 86}

在这种情况下,学生的名字('Alice''Bob''Carol'是字典的键,而他们各自的分数是键映射到的值。一个键不能用于映射到多个不同的值。通过将键传递到方括号内的字典,可以访问给定键的值:

>>> score_dict['Alice']
90
>>> score_dict['Carol']
86
>>> score_dict['Chris']
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyError: 'Chris'

请注意,在前面代码段的最后一条语句中,'Chris'不是字典中的键,因此当我们尝试访问其值时,KeyError由 Python 解释器返回。

可以使用相同的语法更改现有键的值或向现有字典添加新的键-值对:

>>> score_dict['Alice'] = 89
>>> score_dict
{'Alice': 89, 'Bob': 85, 'Carol': 86}
>>> score_dict['Chris'] = 85
>>> score_dict
{'Alice': 89, 'Bob': 85, 'Carol': 86, 'Chris': 85}

与列表理解类似,可以使用字典理解来声明 Python 字典。例如,下面的语句初始化一个字典,将从-11(包括)的整数映射到它们各自的平方:

>>> square_dict = {i: i ** 2 for i in range(-1, 2)}
>>> square_dict
{-1: 1, 0: 0, 1: 1}

如我们所见,此字典包含-11之间每x的键值对xx ** 2,这是通过将for循环放置在字典初始化中完成的。

要从字典中删除键值对,我们需要使用del关键字。假设我们要删除'Alice'键及其对应的值。我们会这样做:

>>> del score_dict['Alice']

试图访问已删除的密钥将导致 Python 解释器引发错误:

>>> score_dict['Alice']
KeyError: 'Alice'

Python 字典最重要的一个方面是,只有不可变的对象才能成为字典键。在到目前为止的示例中,我们已经看到字符串和数字作为字典键。列表在初始化后可以进行变异和更改,不能用作字典键;另一方面,元组可以。

练习 1.04:购物车计算

在本练习中,我们将使用字典数据结构构建购物应用程序的框架版本。这将使我们能够审查并进一步了解数据结构以及可应用于该结构的操作。

执行以下步骤以完成此练习:

  1. Create a new Jupyter notebook and declare a dictionary representing any given items available for purchase and their respective prices in the first code cell. Here, we'll add three different types of laptops with their prices in dollars:

    prices = {'MacBook 13': 1300, 'MacBook 15': 2100, \
              'ASUS ROG': 1600}

    笔记

    此处显示的代码段使用反斜杠(\)将逻辑拆分为多行。执行代码时,Python 将忽略反斜杠,并将下一行的代码视为当前行的直接延续。

  2. 在下一个单元格中,初始化表示购物车的字典。字典开始时应该是空的,但它应该将购物车中的一个项目映射到要购买的副本数量:

    cart = {}
  3. 在一个新的单元格中,写一个while True循环,表示购物过程的每个步骤,并询问用户是否愿意继续购物。使用条件来处理输入的不同情况(您可以将用户希望继续购物的情况留到下一步):

    while True:
        _continue = input('Would you like to continue '\
                          'shopping? [y/n]: ')
        if _continue == 'y':
            ...
        elif _continue == 'n':
            break
        else:
            print('Please only enter "y" or "n".')
  4. 在第一个条件案例中,接受另一个用户输入,询问应该将哪个项目添加到购物车中。使用条件增加cart字典中项目的计数或处理无效案例:

        if _continue == 'y':
            print(f'Available products and prices: {prices}')
            new_item = input('Which product would you like to '\
                             'add to your cart? ')
            if new_item in prices:
                if new_item in cart:
                    cart[new_item] += 1
                else:
                    cart[new_item] = 1
            else:
                print('Please only choose from the available products.')
  5. 在下一个单元格中,循环浏览cart字典,计算用户必须支付的总金额(通过查找购物车中每种商品的数量和价格):

    # Calculation of total bill.
    running_sum = 0
    for item in cart:
        running_sum += cart[item] * prices[item]  # quantity times price
  6. 最后,在一个新的单元格中,通过for循环以不同的行打印购物车中的项目及其各自的金额,最后打印总账单。使用 f 字符串格式化打印输出:

    print(f'Your final cart is:')
    for item in cart:
        print(f'- {cart[item]} {item}(s)')
    print(f'Your final bill is: {running_sum}')
  7. Run the program and experiment with different carts to ensure our program is correct. For example, if you were to add two MacBook 13s and one ASUS ROG to my shopping cart and stop, the corresponding output would be as follows:

    Figure 1.2: Output of the shopping cart application

图 1.2:购物车应用程序的输出

我们的购物车练习到此结束,通过这个练习,我们已经熟悉了使用字典查找信息。我们还回顾了在 Python 程序中使用条件和循环来实现控制流方法的情况。

笔记

要访问此特定部分的源代码,请参考https://packt.live/2C1Ra1C.

您也可以在在线运行此示例 https://packt.live/31F7QXg.

在下一节中,我们将讨论任何复杂程序的两个组成部分:函数和算法。

函数和算法

函数表示 Python 编程中的一个特定对象,我们可以用它来排序和分解程序,而术语算法通常是指处理给定输入数据的逻辑序列的一般组织。在数据科学和科学计算中,算法无处不在,通常采用机器学习模型的形式,用于处理数据并可能进行预测。

在本节中,我们将讨论 Python 函数的概念和语法,然后讨论一些示例算法设计问题。

功能

在最抽象的定义中,函数只是一个对象,它可以根据给定的指令集接收输入并生成输出。Python 函数的形式如下:

def func_name(param1, param2, ...):
     […]
    return […]

def关键字表示 Python 函数的开始。函数的名称可以是任何名称,但规则是避免在名称开头使用特殊字符,并使用 snake case。括号之间是函数接受的参数,这些参数用逗号分隔,可以在函数的缩进代码中使用。

例如,以下函数接收字符串(尽管未指定此要求)并打印问候语:

>>> def greet(name):
...     print(f'Hello, {name}!')

然后,我们可以在我们想要的任何字符串上调用这个函数,并通过函数中的指令实现我们想要的效果。如果我们以某种方式错误指定了函数接受的参数(例如,以下代码段中的最后一条语句),解释器将返回错误:

>>> greet('Quan')
Hello, Quan!
>>> greet('Alice')
Hello, Alice!
>>> greet()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: greet() missing 1 required positional argument: 'name'

需要注意的是,任何局部变量(函数内部声明的变量)都不能在函数范围之外使用。换句话说,一旦函数完成执行,其他代码将无法访问它的任何变量。

大多数情况下,我们希望函数在最后返回某种值,这是由return关键字促成的。一旦return语句被执行,程序的执行将退出给定函数并返回调用该函数的父作用域。这允许我们设计一些动态逻辑序列。

例如,假设一个函数接受一个 Python 整数列表,并返回可被 2 整除的第一个元素(如果列表中没有偶数元素,则返回False):

def get_first_even(my_list):
    [...]
    return  # should be the first even element

现在,编写此函数的自然方法是循环列表中的元素并检查它们的2-可整除性:

def get_first_even(my_list):
    for item in my_list:
        if item % 2 == 0:
            [...]
    return  # should be the first even element

但是,如果满足条件(即,当我们正在迭代的当前元素可被2整除时),该元素应该是函数的返回值,因为它是列表中可被2整除的第一个元素。这意味着我们实际上可以在if块内返回它(最后在函数末尾返回False

def get_first_even(my_list):
    for item in my_list:
        if item % 2 == 0:
            return item
    return False

这种方法与另一种版本形成对比,在这种版本中,我们只在循环结束时返回满足条件的元素,这将更加耗时(执行方面),并且需要额外检查输入列表中是否有偶数元素。在下一个练习中,我们将深入研究这种逻辑的变体。

练习 1.05:找到最大值

在任何编程入门课程中,查找数组或列表的最大值/最小值都是常见的练习。在这个练习中,我们将考虑这个问题的一个改进版本,在其中我们需要写一个函数,它返回索引和列表中最大元素的实际值(如果需要打结,我们返回最后的最大元素)。

执行以下步骤以完成此练习:

  1. 创建一个新的 Jupyter 笔记本,并在代码单元中声明目标函数的一般结构:

    def get_max(my_list):
        ...
        return ...
  2. 创建一个变量,跟踪当前最大元素的索引,称为running_max_index,该变量应初始化为0

    def get_max(my_list):
        running_max_index = 0
        ...
        return ...
  3. 使用for循环和enumerate操作

    def get_max(my_list):
        running_max_index = 0
        # Iterate over index-value pairs.
        for index, item in enumerate(my_list):
             [...]
        return ...

    循环参数列表中的值及其对应的索引

  4. 在迭代的每个步骤中,检查当前元素是否大于或等于与正在运行的索引变量对应的元素。如果是这种情况,则将当前元素的索引分配给运行的最大索引:

    def get_max(my_list):
        running_max_index = 0
        # Iterate over index-value pairs.
        for index, item in enumerate(my_list):
            if item >= my_list[running_max_index]:
                running_max_index = index
        return [...]
  5. 最后,将运行的最大索引及其对应值作为元组返回:

    def get_max(my_list):
        running_max_index = 0
        # Iterate over index-value pairs.
        for index, item in enumerate(my_list):
            if item >= my_list[running_max_index]:
                running_max_index = index
        return running_max_index, my_list[running_max_index]
  6. 在新的单元格中,在各种列表上调用此函数以测试不同的情况。这方面的一个例子如下:

    >>> get_max([1, 3, 2])
    (1, 3)
    >>>  get_max([1, 3, 56, 29, 100, 99, 3, 100, 10, 23])
    (7, 100)

这个练习帮助我们回顾了 Python 函数的一般语法,还提供了循环的复习。此外,我们所考虑的逻辑变化通常在科学计算项目中发现(例如,在迭代器中找到满足某些给定条件的最小值或元素)。

笔记

要访问此特定部分的源代码,请参考https://packt.live/2Zu6KuH.

您也可以在在线运行此示例 https://packt.live/2BUNjDk.

接下来,让我们讨论一种非常特殊的函数设计风格,称为递归

递归

编程中的术语递归表示通过让函数递归地调用自身来使用函数解决问题的方式。其思想是,每次调用函数时,其逻辑都会朝着问题的解决方向迈出一小步,通过多次这样做,原始问题将最终得到解决。我们的想法是,如果我们有办法将我们的问题转化为一个可以用同样方法解决的小问题,我们就可以反复分解问题,得出基本情况,并确保原来更大的问题得到解决。

考虑计算 Ty0 T0 n n 个 T1 整数的和的问题。如果我们已经有了第一个n-1整数的总和,那么我们可以简单地将最后一个数字加到该总和中,计算出n个数字的总和。但如何计算前n-1个数字的总和?通过递归,我们再次假设,如果我们有第一个n-2数字的总和,那么我们将最后一个数字相加。这个过程会重复,直到我们到达列表中的第一个数字,整个过程就完成了。

让我们在下面的例子中考虑这个函数:

>>> def find_sum(my_list):
...     if len(my_list) == 1:
...             return my_list[0]
...     return find_sum(my_list[: -1]) + my_list[-1]

我们可以看到,在一般情况下,函数计算并返回将输入列表的最后一个元素my_list[-1]添加到没有最后一个元素my_list[: -1]的子列表的总和中的结果,该结果由find_sum()函数本身计算。再次,我们合理化了,如果find_sum()函数能够以某种方式解决在较小情况下求和列表的问题,我们可以将结果推广到任何给定的非空列表。

因此,处理基本情况是任何递归算法不可分割的一部分。在这里,我们的基本情况是当输入列表是单值列表(由if语句检查)时,在这种情况下,我们应该简单地返回列表中的元素。

我们可以看到,此函数正确计算任何非空整数列表的总和,如下所示:

>>> find_sum([1, 2, 3])
6
>>> find_sum([1])
1

这是一个基本的例子,因为查找列表的和可以通过维护一个运行的和并使用for循环迭代输入列表中的所有元素来轻松完成。事实上,在大多数情况下,递归的效率低于迭代,因为在程序中一个函数接一个函数地重复调用会有很大的开销。

然而,正如我们将在下面的练习中看到的那样,有些情况下,通过将问题的方法抽象为递归算法,我们可以显著简化问题的解决方式。

练习 1.06:河内塔

河内塔是一个著名的数学问题,也是递归的经典应用。问题陈述如下。

有三个磁盘堆栈,可以放置磁盘和n磁盘,所有磁盘的大小都不同。开始时,磁盘按升序(底部最大的磁盘)堆叠在一个堆栈中。在游戏的每个步骤中,我们都可以将一个堆栈的顶部磁盘放在另一个堆栈的顶部(可以是空堆栈),条件是不能将任何磁盘放在比它小的磁盘的顶部。

我们被要求计算将n磁盘的整个堆栈从一个堆栈移动到另一个堆栈所需的最小移动次数。如果我们以线性的方式来考虑这个问题,问题可能会相当复杂,但如果我们采用递归算法,问题就会变得简单。

具体来说,为了移动n磁盘,我们需要将顶部n-1磁盘移动到另一个堆栈中,将底部最大的磁盘移动到最后一个堆栈中,最后将另一个堆栈中的n-1磁盘移动到与最大磁盘相同的堆栈中。现在,假设我们可以计算将*(n-1)磁盘从一个堆栈移动到另一个堆栈所需的最小步数,表示为S(n-1),然后要移动n磁盘,我们需要S(n-1)+1*步数。

这就是问题的递归解析解。现在,让我们编写一个函数来实际计算任何给定n的这个量。

执行以下步骤以完成此练习:

  1. 在一个新的 Jupyter 笔记本中,定义一个函数,该函数接受一个名为n的整数,并返回我们之前得到的数量:

    def solve(n):
        return 2 * solve(n - 1) + 1
  2. 在函数中创建一个条件以处理基本情况,其中n = 1(注意,移动单个磁盘只需一步):

    def solve(n):
        if n == 1:
            return 1
        return 2 * solve(n - 1) + 1
  3. In a different cell, call the function on different inputs to verify that the function returns the correct analytical solution to the problem, which is 2n - 1:

    >>> print(solve(3) == 2 ** 3 - 1)
    True
    >>> print(solve(6) == 2 ** 6 - 1)
    True

    这里,我们使用==操作符比较两个值:来自solve()函数的返回值和解的解析表达式。如果它们相等,我们应该看到布尔值True被打印出来,这是我们在这里进行的两个比较的情况。

虽然本练习中的代码很短,但它说明了递归可以为许多问题提供优雅的解决方案,并有希望巩固我们对递归算法过程的理解(包括一般步骤和基本情况)。

笔记

要访问此特定部分的源代码,请参考https://packt.live/2NMrGrk.

您也可以在在线运行此示例 https://packt.live/2AnAP6R.

接下来,我们将继续讨论下一节中算法设计的一般过程。

算法设计

设计算法实际上是我们一直在做的事情,特别是在本节中,这是关于函数和算法的:讨论函数对象应该接受什么,它应该如何处理输入,以及在执行结束时应该返回什么输出。在这一节中,我们将简要地讨论一般算法设计过程中的一些实践,然后考虑一个有点复杂的问题,称为“To.T0. N 皇后问题”作为一个练习。

在编写 Python 函数时,一些程序员可能会选择实现子函数(其他函数中的函数)。按照软件开发中的封装思想,当一个子函数仅由另一个函数中的指令调用时,应该实现它。如果是这种情况,第一个函数可以被视为第二个函数的辅助函数,因此应该被放置在第二个函数的内*。这种形式的封装使我们能够更好地组织程序/代码,并确保如果一段代码不需要使用给定函数中的逻辑,那么它就不应该访问它。*

下一个讨论点涉及递归搜索算法,我们将在下一个练习中介绍。具体来说,当算法递归地试图找到给定问题的有效解决方案时,它可能会达到没有有效解决方案的状态(例如,当我们试图在只有奇数整数的列表中找到偶数元素时)。这就需要一种方法来表明我们已经达到了无效状态。

在查找第一个偶数示例中,我们选择返回False以指示输入列表仅由奇数组成的无效状态。返回某种类型的标志,例如False0,实际上是一种常见的做法,我们在本章后面的示例中也将遵循这种做法。

记住这一点,让我们开始本节的练习。

练习 1.07:N 皇后问题

数学和计算机科学中的另一个经典算法设计问题,N 皇后问题要求我们将国际象棋游戏中的N皇后棋子放置在NxN棋盘上,以便没有皇后棋子可以攻击另一个棋子。皇后可以攻击另一个共享同一行、列或对角线的棋子,因此问题在于找到皇后棋子的位置组合,以便任意两个给定皇后位于不同的行、列和对角线中。

在本练习中,我们将设计一个回溯算法,该算法针对任何正整数n搜索该问题的有效解。算法如下:

  1. 考虑到问题的要求,我们认为为了放置n个棋子,棋盘的每一行需要包含一个棋子。

  2. For each row, we iteratively go through all the cells of that row and check to see whether a new queen piece can be placed in a given cell:

    A.如果存在这样一个单元格,我们将在该单元格中放置一个片段并移到下一行。

    B 如果当前行的任何单元格中无法放置新的皇后区,则我们知道我们已达到无效状态,因此返回False

  3. 我们重复这个过程,直到找到有效的解决方案。

下图描述了该算法如何与n=4一起工作:

Figure 1.3: Recursion with the N-Queens problem

图 1.3:N 皇后问题的递归

现在,让我们实际实现算法:

  1. 创建一个新的 Jupyter 笔记本。在第一个单元格中,声明一个名为N的变量,以表示棋盘的大小,以及需要放置在棋盘上的皇后棋子的数量:

    N = 8
  2. A chessboard will be represented as a 2D, n x n list with 0 representing an empty cell and 1 representing a cell with a queen piece. Now, in a new code cell, implement a function that takes in a list of this form and print it out in a nice format:

    # Print out the board in a nice format.
    def display_solution(board):
        for i in range(N):
            for j in range(N):
                print(board[i][j], end=' ')
            print()

    请注意,print语句中的end=' '参数指定打印输出不应该以换行符结尾,而应该是空格字符。这样我们就可以使用不同的print语句打印出同一行中的单元格。

  3. In the next cell, write a function that takes in a board, a row number, and a column number. The function should check to see whether a new queen piece can be placed on this board at the location given by the row and column numbers.

    请注意,由于我们是逐行迭代放置工件,每次检查是否可以在给定位置放置新工件时,我们只需要检查位置上方的行:

    # Check if a queen can be placed in the position.
    def check_next(board, row, col):
        # Check the current column.
        for i in range(row):
            if board[i][col] == 1:
                return False
        # Check the upper-left diagonal.
        for i, j in zip(range(row, -1, -1), \
                        range(col, -1, -1)):
            if board[i][j] == 1:
                return False
        # Check the upper-right diagonal.
        for i, j in zip(range(row, -1, -1), \
                        range(col, N)):
            if board[i][j] == 1:
                return False
        return True
  4. In the same code cell, implement a function that takes in a board and a row number. This function should go through all the cells in the given row and check to see whether a new queen piece can be placed at a particular cell (using the check_next() function written in the preceding step).

    对于这样的单元,在该单元中放置皇后(通过将单元值更改为1),并使用下一行号递归调用函数本身。如果最终解决方案有效,返回True;否则,从单元中移除皇后区(将其更改回0)。

    如果在考虑了给定行的所有单元格后,没有找到有效的解决方案,则返回False以指示invalid状态。该函数在开始时还应该有一个条件来检查行号是否大于电路板大小N,在这种情况下,我们只需返回True以表示我们已达到有效的最终解决方案:

    def recur_generate_solution(board, row_id):
        # Return if we have reached the last row.
        if row_id >= N:
            return True
        # Iteratively try out cells in the current row.
        for i in range(N):
            if check_next(board, row_id, i):
                board[row_id][i] = 1 
                # Return if a valid solution is found.
                final_board = recur_generate_solution(\
                              board, row_id + 1)
                if final_board:
                    return True
                board[row_id][i] = 0  
        # When the current board has no valid solutions.
        return False
  5. In the same code cell, write a final solver function that wraps around the two functions, check_next() and recur_generate_solution() (in other words, the two functions should be subfunctions of the function we are writing). The function should initialize an empty 2D n x n list (representing the chessboard) and call the recur_generate_solution() function with row number 0.

    函数还应在末尾打印出解决方案:

    # Generate a valid solution.
    def generate_solution():
        # Check if a queen can be placed in the position.
        def check_next(board, row, col):
            [...]
        # Recursively generate a solution.
        def recur_generate_solution(board, row_id):
            [...]
        # Start out with en empty board.
        my_board = [[0 for _ in range(N)] for __ in range(N)]
        final_solution = recur_generate_solution(my_board, 0)
        # Display the final solution.
        if final_solution is False:
            print('A solution cannot be found.')
        else:
            print('A solution was found.')
            display_solution(my_board)
  6. 在不同的代码单元中,运行上一步的总体功能,生成并打印解决方案:

    >>> generate_solution()
    A solution was found.
    1 0 0 0 0 0 0 0 
    0 0 0 0 1 0 0 0 
    0 0 0 0 0 0 0 1 
    0 0 0 0 0 1 0 0 
    0 0 1 0 0 0 0 0 
    0 0 0 0 0 0 1 0 
    0 1 0 0 0 0 0 0 
    0 0 0 1 0 0 0 0 

在整个练习过程中,我们实现了一个回溯算法,该算法旨在通过迭代地向潜在解决方案移动(将皇后块放置在安全单元中)来搜索有效解决方案,如果该算法以某种方式达到无效状态,它将通过撤销其先前的移动来回溯(在我们的例子中,通过移除我们放置的最后一块)并寻找新的动作。正如您可能知道的,回溯与递归密切相关,这就是为什么我们选择使用递归函数实现算法,从而巩固我们对一般概念的理解。

笔记

要访问此特定部分的源代码,请参考https://packt.live/2Bn7nyt.

您也可以在在线运行此示例 https://packt.live/2ZrKRMQ.

在本章的下一个和最后一节中,我们将考虑 Python 编程中经常忽略的一些管理任务,即调试、测试和版本控制。

测试、调试、版本控制

需要注意的是,在编程中,编写代码的实际任务并不是过程中的唯一元素。还有其他一些在管道中发挥重要作用的行政程序常常被忽视。在本节中,我们将逐个讨论每一个任务,并考虑在 Python 中实现它们的过程,从测试开始。

测试

为了确保我们编写的软件能够按预期工作并产生正确的结果,有必要对其进行特定的测试。在软件开发中,有许多类型的测试可以应用于程序:集成测试、回归测试、系统测试等等。其中最常见的是单元测试,这是本节讨论的主题。

单元测试表示关注软件的单个小单元,而不是整个程序。单元测试通常是测试管道的第一步一旦我们合理地确信我们程序的各个组件工作正常,我们就可以继续测试这些组件如何协同工作,并查看它们是否产生我们想要的结果(通过集成或系统测试)。

使用unittest模块可以轻松实现 Python 中的单元测试。采用面向对象的方法,unittest允许我们将程序的测试设计为 Python 类,从而使过程更加模块化。这样的类需要从unittest继承TestCase类,单独的测试需要在单独的功能中实现,如下所示:

import unittest
class SampleTest(unittest.TestCase):
    def test_equal(self):
        self.assertEqual(2 ** 3 - 1, 7)
        self.assertEqual('Hello, world!', 'Hello, ' + 'world!')

    def test_true(self):
        self.assertTrue(2 ** 3 < 3 ** 2)
        for x in range(10):
            self.assertTrue(- x ** 2 <= 0)

SampleTest类中,我们放置了两个测试用例,希望使用test_equal()函数中的assertEqual()方法检查两个给定量是否相等。这里,我们测试 23-1 是否真的等于 7,以及 Python 中的字符串连接是否正确。

同样,在test_true()功能测试中使用的assertTrue()方法,用于确定给定参数是否被评估True。这里,我们测试 23 是否小于 32,以及 0 到 10 之间整数的完美平方的负数是否为非正。

要运行我们已经实现的测试,我们可以使用以下语句:

>>> unittest.main()
test_equal (__main__.SampleTest) ... ok
test_true (__main__.SampleTest) ... ok
----------------------------------------------------------------------
Ran 2 tests in 0.001s
OK

生成的输出告诉我们,我们的两个测试都返回阳性。需要记住的一个重要的补充说明是,如果您在 Jupyter 笔记本中运行单元测试,最后一条语句需要如下所示:

unittest.main(argv=[''], verbosity=2, exit=False)

由于单元测试将作为 Python 类中的函数实现,unittest模块还提供了两种方便的方法,setUp()tearDown(),分别在每次测试之前和之后自动运行。我们将在下一个练习中看到这方面的示例。现在,我们将继续讨论调试。

调试

术语调试字面意思是从给定的计算机程序中删除一个或多个 bug,从而使其正常工作。在大多数情况下,调试过程在失败的测试之后进行,测试确定程序中存在错误。然后,为了调试程序,我们需要确定导致测试失败的错误源,并尝试修复与该错误相关的代码。

程序可能采用多种形式的调试。这些措施包括:

  • 打印调试:打印调试可以说是最常见和最基本的调试方法之一,打印调试包括识别可能导致错误的变量,将这些变量的print语句放在程序中的不同位置,以便我们跟踪这些变量值的变化。一旦发现变量值的变化是不需要的或不需要的,我们将查看print语句在程序中的具体位置,从而(粗略地)确定错误的位置。
  • 日志记录:如果我们不将变量的值打印到标准输出,而是决定将输出写入日志文件,这称为日志记录。日志记录通常用于跟踪我们正在调试或监视的程序执行过程中发生的特定事件。
  • 跟踪:为了调试一个程序,在本例中,我们将遵循程序执行时的底层函数调用和执行堆栈。通过从低级角度观察变量和函数的使用顺序,我们也可以确定错误的来源。可以使用sys模块中的sys.settrace()方法在 Python 中实现跟踪。

在 Python 中,使用打印调试非常容易,因为我们只需要使用print语句。对于更复杂的功能,我们可以使用调试器,这是一个专门为调试目的而设计的模块/库。Python 中最主要的调试器是内置的pdb模块,它过去是通过pdb.set_trace()方法运行的。

从 Python 3.7 开始,我们可以通过调用内置的breakpoint()函数来选择更简单的语法。在调用breakpoint()函数的每个地方,程序的执行都将暂停,并允许我们检查程序的行为和当前特征,包括其变量的值。

具体来说,一旦程序的执行达到breakpoint()功能,就会出现一个输入提示,我们可以在这里输入pdb命令。模块文档中包含了许多可以利用的命令。以下是一些值得注意的命令:

  • h:用于帮助,打印出您可以使用的命令的完整列表。
  • u/d:分别用于向上向下,在一个方向上移动运行帧计数一级。
  • s:对于步骤,执行程序当前所在的指令,并在执行过程中的第一个可能位置暂停。这个命令在观察一行代码对程序状态的直接影响方面非常有用。
  • n:对于next,它执行程序当前所在的指令,仅在当前函数的下一条指令和执行返回时暂停。该命令的工作原理与s类似,但它以更高的速度跳过指令。
  • r:对于返回,继续执行,直到当前函数返回。
  • c:对于continue,继续执行,直到到达下一条breakpoint()语句。
  • ll:用于长列表,打印出当前指令的源代码。
  • p [expression]:对于打印,计算并打印出给定表达式的值

总的来说,一旦breakpoint()语句暂停了程序的执行,我们就可以利用前面不同命令的组合来检查程序的状态并识别潜在的 bug。在下面的练习中,我们将看一个这样的例子。

练习 1.08:并发性测试

在本练习中,我们将考虑并发或并行相关程序中的一个众所周知的 bug,称为 OrthT2 竞赛条件。这将作为一个很好的用例来试用我们的测试和调试工具。由于 Jupyter 笔记本中的pdb和其他调试工具的集成尚不成熟,因此我们将在本练习中使用.py脚本。

执行以下步骤以完成此练习:

  1. 我们的程序设置(在以下步骤中实现)如下所示。我们有一个类,它实现了一个计数器对象,可以由多个线程并行操作。每次调用该计数器对象的update()方法时,该计数器对象的实例(存储在其value属性中,初始化为0)的值都会递增。计数器还有一个目标,其值应增加到该目标。当调用其run()方法时,将产生多个线程。每个线程都将调用update()方法,从而将其value属性增加一个等于原始目标的次数。理论上,计数器的最终值应该与目标值相同,但我们将看到,由于竞争条件,情况并非如此。我们的目标是应用pdb跟踪该程序内变量的变化,以分析该竞赛条件。

  2. Create a new .py script and enter the following code:

    import threading
    import sys; sys.setswitchinterval(10 ** -10)
    class Counter:
        def __init__(self, target):
            self.value = 0
            self.target = target        
        def update(self):
            current_value = self.value
            # breakpoint()
            self.value = current_value + 1
    
        def run(self):
            threads = [threading.Thread(target=self.update) \
                                        for _ in range(self.target)]
            for t in threads:
                t.start()
            for t in threads:
                t.join()

    这段代码实现了我们前面讨论过的Counter类。请注意,有一行代码用于设置系统的切换间隔;我们稍后会讨论这个问题。

  3. With the hope that the value of a counter object should be incremented to its true target, we will test its performance with three different target values. In the same .py script, enter the following code to implement our unit tests:

    import unittest
    class TestCounter(unittest.TestCase):
        def setUp(self):
            self.small_params = 5
            self.med_params = 5000
            self.large_params = 10000
    
        def test_small(self):
            small_counter = Counter(self.small_params)
            small_counter.run()
            self.assertEqual(small_counter.value, \
                             self.small_params)
    
        def test_med(self):
            med_counter = Counter(self.med_params)
            med_counter.run()
            self.assertEqual(med_counter.value, \
                             self.med_params)
    
        def test_large(self):
            large_counter = Counter(self.large_params)
            large_counter.run()
            self.assertEqual(large_counter.value, \
                             self.large_params)
        if __name__ == '__main__':
            unittest.main()

    在这里,我们可以看到,在每个测试函数中,我们初始化一个新的counter对象,运行它,最后将其值与真实目标进行比较。测试用例的目标在setUp()方法中声明,正如我们前面提到的,该方法在执行测试之前运行:

    Run this Python script:test_large (__main__.TestCounter) ... FAIL
    test_med (__main__.TestCounter) ... FAIL
    test_small (__main__.TestCounter) ... ok
    ====================================================================
    FAIL: test_large (__main__.TestCounter)
    --------------------------------------------------------------------
    Traceback (most recent call last):
        File "<ipython-input-57-4ed47b9310ba>", line 22, in test_large
        self.assertEqual(large_counter.value, self.large_params)
    AssertionError: 9996 != 10000
    ====================================================================
    FAIL: test_med (__main__.TestCounter)
    --------------------------------------------------------------------
    Traceback (most recent call last):
        File "<ipython-input-57-4ed47b9310ba>", line 17, in test_med
        self.assertEqual(med_counter.value, self.med_params)
    AssertionError: 4999 != 5000
    --------------------------------------------------------------------
    Ran 3 tests in 0.890s
    FAILED (failures=2)

    如您所见,程序在两次测试中失败:test_med(计数器的最终值只有 4999 而不是 5000)和test_large(值是 9996 而不是 10000)。您可能会获得不同的输出。

  4. 多次重新运行此代码单元格,以查看结果是否可能有所不同。

  5. 现在我们知道程序中有一个 bug,我们将尝试调试它。通过在update()方法中的两条指令之间放置breakpoint()语句,重新实现我们的Counter类,如下面的代码所示,并重新运行代码单元:

    class Counter:
        ...
        def update(self):
            current_value = self.value
            breakpoint()
            self.value = current_value + 1
        ...
  6. In the main scope of our Python script, comment out the call to the unit tests. Instead, declare a new counter object and run the script using the Terminal:

    sample_counter = Counter(10)
    sample_counter.run()

    在这里,您将看到终端中出现一个pdb提示(您可能需要先按Enter键才能使调试器继续进行):

    Figure 1.4: pdb interface

    图 1.4:pdb 接口

  7. Input ll and hit Enter to see where in the program we are pausing:

    (Pdb) ll
      9         def update(self):
     10             current_value = self.value
     11             breakpoint()
     12  ->         self.value = current_value + 1

    这里,输出表明我们当前正在两条指令之间暂停,这两条指令在update()方法中增加计数器的值。

  8. Hit Enter again to return to the pdb prompt and run the p self.value command:

    (Pdb) p self.value
    0

    我们可以看到计数器的当前值为0

  9. 返回提示并输入n命令。在此之后,再次使用p self.value命令检查计数器的值:

    (Pdb) n
    --Return--
    > <ipython-input-61-066f5069e308>(12)update()->None
    -> self.value = current_value + 1
    (Pdb) p self.value
    1
  10. 我们可以看到该值已增加 1。重复这个在np self.value之间交替的过程,观察self.value中存储的值在我们继续执行程序时没有更新。换句话说,该值通常保持为 1。正如我们在单元测试中看到的那样,这就是 bug 如何在计数器的大值中表现出来的。

  11. Exit the debugger using Ctrl + C.

笔记

要访问此特定部分的源代码,请参考[https://packt.live/2YPCZFJ](https://packt.live/2YPCZFJ) 。

本节目前没有在线交互示例,需要在本地运行。

对于那些感兴趣的人来说,我们程序的错误源于这样一个事实:多个线程可以在大致相同的时间增加计数器的值,从而覆盖彼此所做的更改。有了大量线程(比如我们在测试用例中有 5000 或 10000 个线程),发生此事件的概率会更高。正如我们前面提到的,这种现象被称为竞争条件,它是并发和并行程序中最常见的错误之一。

除了演示一些pdb命令外,本练习还说明了设计测试以覆盖不同情况的必要性。虽然程序通过了我们的小测试,目标值为 5,但由于目标值较大而失败。在现实生活中,我们应该对程序进行测试,以模拟各种可能性,确保程序仍能按预期工作,即使在边缘情况下也是如此。

接下来,让我们进入本章的最后一个主题,版本控制。

版本控制

在本节中,我们将简要介绍版本控制背后的一般理论,然后讨论使用 Git 和 GitHub 实现版本控制的过程,Git 和 GitHub 可以说是业界最流行的版本控制系统。版本控制对于编程项目来说就像备份数据到常规文件一样重要。从本质上说,版本控制系统允许我们将项目进度与本地文件分开保存,这样即使本地文件丢失或损坏,我们也可以稍后再查看。

有了当前版本控制系统(如 Git 和 GitHub)提供的功能,我们还可以做更多的事情。例如,这些系统的分支和合并功能为用户提供了一种方法,可以创建一个公共项目的多个版本,以便探索不同的方向;最后,实现最首选方向的分支将与主分支合并。此外,Git 和 GitHub 允许用户在其平台上无缝工作,这在团队项目中非常受欢迎。

为了了解 Git 和 GitHub 可以利用的可用特性,让我们进行以下练习。

练习 1.09:使用 Git 和 GitHub 进行版本控制

本练习将引导我们完成开始使用 Git 和 GitHub 所需的所有步骤。如果您还没有使用版本控制的任何经验,此练习将对您有益。

执行以下步骤以完成此练习:

  1. 首先,如果您还没有注册 GitHub 帐户,请转到https://www.github.com/ 并注册。这将允许您在其云存储上托管要进行版本控制的文件。

  2. Go to https://git-scm.com/downloads and download the Git client software for your system and install it. This Git client will be responsible for communicating with the GitHub server. You know if your Git client is successfully installed if you can run the git command in your Terminal:

    $ git
    usage: git [--version] [--help] [-C <path>] [-c <name>=<value>]
               [--exec-path[=<path>]] [--html-path] [--man-path] [--info-path]
               [-p | --paginate | -P | --no-pager] [--no-replace-objects] [--bare]
               [--git-dir=<path>] [--work-tree=<path>] [--namespace=<name>]
               <command> [<args>]

    否则,可能需要重新启动系统才能使安装完全生效。

  3. 现在,让我们开始将版本控制应用于示例项目的过程。首先,创建一个虚拟文件夹,生成一个 Jupyter 笔记本和一个名为input.txt的文本文件,其中包含以下内容:

    1,1,1
    1,1,1
  4. 在 Jupyter 笔记本的第一个单元格中,编写一个名为add_elements()的函数,该函数接收两个数字列表,并按元素将它们相加。函数应该返回一个列表,该列表由元素和组成;您可以假设两个参数列表的长度相同:

    def add_elements(a, b):
        result = []
        for item_a, item_b in zip(a, b):
            result.append(item_a + item_b)
        return result
  5. In the next code cell, read in the input.txt file using a with statement and extract the last two lines of the file using the readlines() function and list indexing:

    with open('input.txt', 'r') as f:
        lines = f.readlines()
    last_line1, last_line2 = lines[-2], lines[-1]

    注意,在open()函数中,第二个参数'r'指定我们正在读取文件,而不是写入文件。

  6. 在一个新的代码单元中,将这两个输入的文本字符串转换为数字列表,首先使用带','参数的str.split()函数分离每行中的单个数字,然后使用map()int()函数将转换应用于整数元素:

    list1 = list(map(int, last_line1[: -1].split(',')))
    list2 = list(map(int, last_line2[: -1].split(',')))
  7. In a new code cell, call add_elements() on list1 and list2. Write the returned list to the same input file in a new line in the same comma-separated values (CSV) format:

    new_list = add_elements(list1, list2)
    with open('input.txt', 'a') as f:
        for i, item in enumerate(new_list):
            f.write(str(item))
    
            if i < len(new_list) - 1:
                f.write(',')
            else:
                f.write('\n')

    在这里,'a'参数指定我们正在写入以向文件追加新行,而不是完全读取和覆盖文件。

  8. 运行代码单元格并验证文本文件已更新为以下内容:

    1,1,1
    1,1,1
    2,2,2
  9. This is the current setup of our sample project so far: we have a text file and a Python script inside a folder; the script can alter the content of the text file when run. This setup is fairly common in real-life situations: you can have a data file that contains some information that you'd like to keep track of and a Python program that can read in that data and update it in some way (maybe through prespecified computation or adding new data that was collected externally).

    现在,让我们在这个示例项目中实现版本控制。

  10. Go to your online GitHub account, click on the plus sign icon (+) in the top-right corner of the window, and choose the New repository option, as illustrated here:

![Figure 1.5: Creating a new repository ](img/B15968_01_05.jpg)

图 1.5:创建新存储库

在表单中输入新存储库的示例名称,并完成创建过程。将此新存储库的 URL 复制到您的剪贴板,因为我们稍后将需要它。

顾名思义,这将创建一个新的在线存储库,它将承载我们想要进行版本控制的代码。
  1. 在本地计算机上,打开终端并导航到该文件夹。运行以下命令初始化本地 Git 存储库,该存储库将与我们的文件夹关联:
```py
$ git init
```
  1. Still in the Terminal, run the following command to add everything in our project to Git and commit them:
```py
git add .
git commit -m [any message with double quotes]
```

您可以用要向 Git 注册的文件名替换`.`,而不是`git add .`。当您只想注册一两个文件,而不是文件夹中的每个文件时,此选项非常有用。
  1. Now, we need to link our local repository and the online repository that we have created. To do that, run the following command:
```py
git remote add origin [URL to GitHub repository]
```

请注意,“origin”只是 URL 的传统昵称。
  1. 最后,通过运行以下命令将本地注册的文件上传到在线存储库:
```py
git push origin master
```
  1. 转到在线存储库的网站,验证我们创建的本地文件是否确实已上载到 GitHub。
  2. 在本地计算机上,运行 Jupyter 笔记本中包含的脚本并更改文本文件。
  3. 现在,我们想将此更改提交到 GitHub 存储库。在您的终端中,再次运行以下命令:
```py
git add .
git commit
git push origin master
```
  1. 转到或刷新 GitHub 网站,以验证我们第二次所做的更改是否也已在 GitHub 上进行。

在本练习中,我们浏览了一个示例版本控制管道,并看到了一些关于如何在这方面使用 Git 和 GitHub 的示例。我们还看到了关于使用with语句在 Python 中读取和写入文件的过程的更新。

笔记

要访问此特定部分的源代码,请参考https://packt.live/2VDS0IS.

您也可以在在线运行此示例 https://packt.live/3ijJ1pM.

这也是本书第一章的最后一个主题。在下一节中,我们提供了一个活动,它将作为一个实践项目,封装我们在本章中讨论过的重要主题和讨论。

活动活动 1.01:构建数独解算器

让我们用一个更复杂的问题来测试我们到目前为止学到的东西:编写一个可以解决数独难题的程序。程序应该能够读取 CSV 文本文件作为输入(其中包含初始拼图),并输出该拼图的完整解决方案。

该活动作为一个热身活动,由科学计算和数据科学项目中常见的多个过程组成,例如从外部文件读取数据,并通过算法处理该信息。

  1. 使用本章 GitHub 存储库中的sudoku_input_2.txt文件作为我们程序的输入文件,方法是将其复制到下一步将创建的 Jupyter 笔记本的相同位置(或者以相同格式创建自己的输入文件,其中空单元格用零表示)。

  2. 在新 Jupyter 笔记本的第一个代码单元中,创建一个Solver类,该类接受输入文件的路径。它应该将从输入文件读取的信息存储在一个 9x92D 列表中(一个包含九个子列表的列表,每个子列表包含拼图中的九个单独行的值)。

  3. 添加一个助手方法,该方法以漂亮的格式打印出拼图,如下所示:

    -----------------------
    0 0 3 | 0 2 0 | 6 0 0 | 
    9 0 0 | 3 0 5 | 0 0 1 | 
    0 0 1 | 8 0 6 | 4 0 0 | 
    -----------------------
    0 0 8 | 1 0 2 | 9 0 0 | 
    7 0 0 | 0 0 0 | 0 0 8 | 
    0 0 6 | 7 0 8 | 2 0 0 | 
    -----------------------
    0 0 2 | 6 0 9 | 5 0 0 | 
    8 0 0 | 2 0 3 | 0 0 9 | 
    0 0 5 | 0 1 0 | 3 0 0 | 
    -----------------------
  4. Create a get_presence(cells) method in the class that takes in any 9 x 9 2D list, representing an unsolved/half-solved puzzle, and returns a sort of indicator regarding whether a given number (between 1 and 9) is present in a given row, column, or quadrant.

    例如,在前面的示例中,此方法的返回值应该能够告诉您第一行中有 2、3 和 6,而第二列中没有数字。

  5. Create a get_possible_values(cells) method in the class that also takes in any 2D list representing an incomplete solution and returns a dictionary, whose keys are the locations of currently empty cells and the corresponding values are the lists/sets of possible values that those cells can take.

    生成这些可能值的列表时,应考虑数字是否与给定的空单元格位于同一行、列或象限中。

  6. Create a simple_update(cells) method in the class that takes in any 2D incomplete solution list and calls the get_possible_values() method on that list. From the returned value, if there is an empty cell that holds only one possible solution, update that cell with that value.

    如果确实发生了这样的更新,则该方法应再次调用自身以继续更新单元格。这是因为更新后,剩余空单元格的可能值列表可能会更改。该方法最后应返回更新的 2D 列表。

  7. Create a recur_solve(cells) method in the class that takes in any 2D incomplete solution list and performs backtracking. First, this method should call simple_update() and return whether or not the puzzle is completely solved (that is, whether or not there are empty cells in the 2D list).

    接下来,考虑剩余空单元的可能值。如果剩余的单元格为空,并且您没有可能的值,则返回一个否定结果,表明我们得到了无效的解决方案。

    另一方面,如果所有单元格都至少有两个可能值,则查找可能值最少的单元格。循环遍历这些可能的值,依次将它们填充到空单元格中,并使用更新的单元格调用自身内部的recur_solve(),以实现算法的递归性质。在每次迭代中,返回最终解决方案是否有效。如果通过任何可能的值都找不到有效的最终解决方案,则返回负结果。

  8. Wrap the preceding methods in a solve() method, which should print out the initial puzzle, pass it to the recur_solve() method, and print out the returned solution from that method.

    例如,对于前面的谜题,Solver实例在调用solve()时,将打印出以下输出。

    初始谜题:

    -----------------------
    0 0 3 | 0 2 0 | 6 0 0 | 
    9 0 0 | 3 0 5 | 0 0 1 | 
    0 0 1 | 8 0 6 | 4 0 0 | 
    -----------------------
    0 0 8 | 1 0 2 | 9 0 0 | 
    7 0 0 | 0 0 0 | 0 0 8 | 
    0 0 6 | 7 0 8 | 2 0 0 | 
    -----------------------
    0 0 2 | 6 0 9 | 5 0 0 | 
    8 0 0 | 2 0 3 | 0 0 9 | 
    0 0 5 | 0 1 0 | 3 0 0 | 
    -----------------------

    解决难题:

    -----------------------
    4 8 3 | 9 2 1 | 6 5 7 | 
    9 6 7 | 3 4 5 | 8 2 1 | 
    2 5 1 | 8 7 6 | 4 9 3 | 
    -----------------------
    5 4 8 | 1 3 2 | 9 7 6 | 
    7 2 9 | 5 6 4 | 1 3 8 | 
    1 3 6 | 7 9 8 | 2 4 5 | 
    -----------------------
    3 7 2 | 6 8 9 | 5 1 4 | 
    8 1 4 | 2 5 3 | 7 6 9 | 
    6 9 5 | 4 1 7 | 3 8 2 | 
    -----------------------

    扩展

    1.进入欧拉项目网站https://projecteuler.net/problem=96 ,根据所包含的谜题测试您的算法。

    2.编写一个程序,生成数独谜题,并包含单元测试,检查我们的解算器生成的解是否正确。

    笔记

    此活动的解决方案可在第 648 页找到。

总结

本章介绍了 Python 编程最基本的构建块:控制流、数据结构、算法设计和各种内部管理任务(调试、测试和版本控制)。我们在本章中获得的知识将为我们在未来的章节中的讨论做好准备,在这些章节中,我们将学习 Python 中其他更复杂、更专业的工具。特别是在下一章中,我们将讨论 Python 在统计、科学计算和数据科学领域提供的主要工具和库。

PGM59

MAF28