带你迅速搞定Python编程-第 10 章 文件和异常

带你迅速搞定Python编程-第 10 章 文件和异常

至此,你已经掌握了编写整洁易用的程序所需的基本技能,该考虑让程序目标更明确、用途更大了。在本章中,你将学习处理文件,让程序能够快速地分析大量数据;你将学习错误处理,避免程序在面对意外情况时崩溃;你将学习异常,它们是 Python 创建的特殊对象,用于管理程序运行时出现的错误;你还将学习使用 json 模块保存用户数据,以免这些数据在程序结束运行后丢失。

学习处理文件和保存数据能让你的程序更易于使用:用户能够选择输入什么样的数据以及在什么时候输入;用户使用程序做完一些工作后,可先将程序关闭,以后再接着往下做。学习处理异常可帮助你应对文件不存在等情况,以及处理其他可能导致程序崩溃的问题。这让程序在面对错误的数据时更稳健——不管这些错误数据源自无意的错误,还是出于破坏程序的恶意企图。你在本章学习的技能可提高程序的适用性、可用性和稳定性。

10.1 读取文件

文本文件可存储的数据多得令人难以置信:天气数据、交通数据、社会经济数据、文学作品,等等。每当需要分析或修改存储在文件中的信息时,读取文件都很有用,对数据分析应用程序来说尤其如此。例如,可以编写一个程序来读取文本文件的内容,并且以新的格式重写该文件,让浏览器能够显示。

要使用文本文件中的信息,首先需要将信息读取到内存中。既可以一次性读取文件的全部内容,也可以逐行读取。

10.1.1 读取文件的全部内容

要读取文件,需要一个包含若干行文本的文件。下面来创建一个文件,它包含精确到小数点后 30 位的圆周率值,且在小数点后每 10 位处换行:

pi_digits.txt

3.1415926535
  8979323846
  2643383279

要动手尝试后续示例,既可以在编辑器中输入这些数据行,并将文件保存为 pi_digits.txt,也可以从本书主页下载。请将这个文件保存到本章程序所在的目录中。

下面的程序打开并读取这个文件,再将其内容显示到屏幕上:

file_reader.py

  from pathlib import Path

❶ path = Path('pi_digits.txt')
❷ contents = path.read_text()
  print(contents)

要使用文件的内容,需要将其路径告知 Python。路径(path)指的是文件或文件夹在系统中的准确位置。Python 提供了 pathlib 模块,让你能够更轻松地在各种操作系统中处理文件和目录。提供特定功能的模块通常称为库(library)。这就是这个模块被命名为 pathlib 的原因所在。

这里首先从 pathlib 模块导入 Path 类。Path 对象指向一个文件,可用来做很多事情。例如,让你在使用文件前核实它是否存在,读取文件的内容,以及将新数据写入文件。这里创建了一个表示文件 pi_digits.txt 的 Path 对象,并将其赋给了变量 path(见❶)。由于这个文件与当前编写的 .py 文件位于同一个目录中,因此 Path 只需要知道其文件名就能访问它。

注意:VS Code 会在最近打开的文件夹中查找文件,因此如果你使用的是 VS Code,请先打开本章程序所在的文件夹。假如你将本章的程序文件存储在文件夹 chapter_10 中,请按 Ctrl + O(在 macOS 中为 Command + O),并打开这个文件夹。

创建表示文件 pi_digits.txt 的 Path 对象后,使用 read_text() 方法来读取这个文件的全部内容(见❷)。read_text() 将该文件的全部内容作为一个字符串返回,而我们将这个字符串赋给了变量 contents。在打印 contents 的值时,将显示这个文本文件的全部内容:

3.1415926535
  8979323846
  2643383279

相比于原始文件,该输出唯一不同的地方是末尾多了一个空行。为何会多出这个空行呢?因为 read_text() 在到达文件末尾时会返回一个空字符串,而这个空字符串会被显示为一个空行。

要删除这个多出来的空行,可对字符串变量 contents 调用 rstrip():

from pathlib import Path

path = Path('pi_digits.txt')
contents = path.read_text()
contents = contents.rstrip()
print(contents)

第 2 章介绍过,Python 方法 rstrip() 能删除字符串末尾的空白。现在,输出与原始文件的内容完全一致了:

3.1415926535
  8979323846
  2643383279

要在读取文件内容时删除末尾的换行符,可在调用 read_text() 后直接调用方法 rstrip():

contents = path.read_text().rstrip()
这行代码先让 Python 对当前处理的文件调用 read_text() 方法,再对 read_text() 返回的字符串调用 rstrip() 方法,然后将整理好的字符串赋给变量 contents。这种做法称为方法链式调用(method chaining),在编程时很常用。

10.1.2 相对文件路径和绝对文件路径

当将类似于 pi_digits.txt 这样的简单文件名传递给 Path 时,Python 将在当前执行的文件(即 .py 程序文件)所在的目录中查找。

根据你组织文件的方式,有时可能要打开不在程序文件所属目录中的文件。例如,你可能将程序文件存储在了文件夹 python_work 中,并且在文件夹 python_work 中创建了一个名为 text_files 的文件夹,用于存储程序文件要操作的文本文件。虽然文件夹 text_files 在文件夹 python_work 中,但仅向 Path 传递文件夹 text_files 中的文件的名称也是不可行的,因为 Python 只在文件夹 python_work 中查找,而不会在其子文件夹 text_files 中查找。要让 Python 打开不与程序文件位于同一个目录中的文件,需要提供正确的路径。

在编程中,指定路径的方式有两种。首先,相对文件路径让 Python 到相对于当前运行的程序所在目录的指定位置去查找。由于文件夹 text_files 位于文件夹 python_work 中,因此需要创建一个以 text_files 打头并以文件名结尾的路径,如下所示:

path = Path('text_files/filename.txt')

其次,可以将文件在计算机中的准确位置告诉 Python,这样就不用管当前运行的程序存储在什么地方了。这称为绝对文件路径。在相对路径行不通时,可使用绝对路径。假如 text_files 并不在文件夹 python_work 中,则仅向 Path 传递路径 ‘text_files/filename.txt’ 是行不通的,因为 Python 只在文件夹 python_work 中查找该位置。为了明确地指出希望 Python 到哪里去查找,需要提供绝对路径。

绝对路径通常比相对路径长,因为它们以系统的根文件夹为起点:

path = Path('/home/eric/data_files/text_files/filename.txt')

使用绝对路径,可读取系统中任何地方的文件。就目前而言,最简单的做法是,要么将数据文件存储在程序文件所在的目录中,要么将其存储在程序文件所在目录下的一个文件夹(如 text_files)中。

注意:在显示文件路径时,Windows 系统使用反斜杠(\)而不是斜杠(/)。但是你在代码中应该始终使用斜杠,即便在 Windows 系统中也是如此。在与你或其他用户的系统交互时,pathlib 库会自动使用正确的路径表示方法。

10.1.3 访问文件中的各行

在使用文件时,经常需要检查其中的每一行:可能要在文件中查找特定的信息,或者以某种方式修改文件中的文本。例如,在分析天气时,可能要遍历一个包含天气数据的文件,并使用天气描述中包含 sunny 字样的行;在新闻报道中,可能要查找包含标记 的行,并按特定的格式改写它。

你可以使用 splitlines() 方法将冗长的字符串转换为一系列行,再使用 for 循环以每次一行的方式检查文件中的各行:

file_reader.py

from pathlib import Path

  path = Path('pi_digits.txt')
❶ contents = path.read_text()

❷ lines = contents.splitlines()
  for line in lines:
      print(line)

与前面一样,首先读取文件的全部内容(见❶)。如果要处理文件中的各行,就无须在读取文件时删除任何空白。splitlines() 方法返回一个列表,其中包含文件中所有的行,而我们将这个列表赋给了变量 lines(见❷)。然后,遍历这些行并打印它们:

3.1415926535
  8979323846
  2643383279

由于没有修改这些行,因此输出与原始文件完全一致。

10.1.4 使用文件的内容

将文件的内容读取到内存中后,就能以任意方式使用这些数据了。下面以简单的方式使用圆周率的值。首先,创建一个字符串,它包含文件中存储的所有数字,不包含空格:

pi_string.py

  from pathlib import Path

  path = Path('pi_digits.txt')
  contents = path.read_text()

  lines = contents.splitlines()
  pi_string = ''for line in lines:
      pi_string += line

  print(pi_string)
  print(len(pi_string))

像上一个示例一样,首先读取文件,并将其中的所有行都存储在一个列表中。然后,创建变量 pi_string,用于存储圆周率的值。接下来,使用循环将各行加入 pi_string(见❶)。最后,打印这个字符串及其长度:

3.1415926535  8979323846  2643383279
36

变量 pi_string 存储的字符串包含原来位于每行左端的空格。要删除这些空格,可对每行调用 lstrip():

--snip--
for line in lines:
    pi_string += line.lstrip()

print(pi_string)
print(len(pi_string))

这样就获得了一个字符串,其中包含准确到 30 位小数的圆周率值。这个字符串的长度是 32 个字符,因为它还包含整数部分的 3 和小数点:

3.141592653589793238462643383279
32

注意:在读取文本文件时,Python 将其中的所有文本都解释为字符串。如果读取的是数,并且要将其作为数值使用,就必须使用 int() 函数将其转换为整数,或者使用 float() 函数将其转换为浮点数。

10.1.5 包含 100 万位的大型文件

尽管前面分析的都是一个只有三行的文本文件,但是这些代码示例也可以处理比它大得多的文件。如果一个文本文件包含精确到小数点后 1 000 000 位而不是 30 位的圆周率值,也可以创建一个包含所有这些数字的字符串。无须对前面的程序做任何修改,只需将这个文件传递给它即可。在这里,只打印到小数点后 50 位,以免终端花太多时间滚动显示全部的 1 000 000 位数字:

pi_string.py

from pathlib import Path

path = Path('pi_million_digits.txt')
contents = path.read_text()

lines = contents.splitlines()
pi_string = ''
for line in lines:
    pi_string += line.lstrip()

print(f"{pi_string[:52]}...")
print(len(pi_string))

输出表明,创建的字符串确实包含精确到小数点后 1 000 000 位的圆周率值:

3.14159265358979323846264338327950288419716939937510...
1000002

在可处理的数据量方面,Python 没有任何限制。只要系统的内存足够大,你想处理多少数据就可以处理多少数据。

注意:要运行这个程序(以及后面的众多示例),需要从本书主页下载相关的资源。

10.1.6 圆周率值中包含你的生日吗

我一直想知道自己的生日是否包含在圆周率值中。下面来扩展刚才编写的程序,以确定某个人的生日是否包含在圆周率值的前 1 000 000 位中。为此,可先将生日表示为一个由数字组成的字符串,再检查这个字符串是否在 pi_string 中:

pi_birthday.py

--snip--
for line in lines:
    pi_string += line.strip()

birthday = input("Enter your birthday, in the form mmddyy: ")
if birthday in pi_string:
    print("Your birthday appears in the first million digits of pi!")
else:
    print("Your birthday does not appear in the first million digits of pi.")

首先提示用户输入其生日,再检查这个字符串是否在 pi_string 中。运行这个程序:

Enter your birthdate, in the form mmddyy: 120372
Your birthday appears in the first million digits of pi!

我的生日确实出现在了圆周率值中!读取文件的内容后,就能以任意方式对其进行分析了。

动手试一试

练习 10.1:Python 学习笔记 在文本编辑器中新建一个文件,写几句话来总结一下你至此学到的 Python 知识,其中每一行都以“In Python you can”打头。将这个文件命名为 learning_python.txt,并存储到为完成本章练习而编写的程序所在的目录中。编写一个程序,读取这个文件,并将你所写的内容打印两次:第一次打印时读取整个文件;第二次打印时先将所有行都存储在一个列表中,再遍历列表中的各行。

练习 10.2:C 语言学习笔记 可使用 replace() 方法将字符串中的特定单词替换为另一个单词。下面是一个简单的示例,演示了如何将句子中的 ‘dog’ 替换为 ‘cat’:

>>> message = "I really like dogs."
>>> message.replace('dog', 'cat')
'I really like cats.'

读取你刚创建的文件 learning_python.txt 中的每一行,将其中的 Python 都替换为另一门语言的名称,如 C。将修改后的各行都打印到屏幕上。

练习 10.3:简化代码 本节前面的程序 file_reader.py 中使用了一个临时变量 lines,来说明 splitlines() 的工作原理。可省略这个临时变量,直接遍历 splitlines() 返回的列表:

for line in contents.splitlines():

对于本节的每个程序,都删除其中的临时变量,让代码更简洁。

10.2 写入文件

保存数据的最简单的方式之一是将其写入文件。通过将输出写入文件,即便关闭包含程序输出的终端窗口,这些输出也依然存在:既可以在程序结束运行后查看这些输出,也可以与他人共享输出文件,还可以编写程序来将这些输出读取到内存中并进行处理。

10.2.1 写入一行

定义一个文件的路径后,就可使用 write_text() 将数据写入该文件了。为明白其中的工作原理,下面将一条简单的消息存储到文件中,而不将其打印到屏幕上:

write_message.py

from pathlib import Path

path = Path('programming.txt')
path.write_text("I love programming.")

write_text() 方法接受单个实参,即要写入文件的字符串。这个程序没有终端输出,但你如果打开文件 programming.txt,将看到如下一行内容:

programming.txt

I love programming.

这个文件与计算机中的其他文件没有什么不同。你可以打开它,在其中输入新文本,复制其内容,将内容粘贴到其中,等等。

注意:Python 只能将字符串写入文本文件。如果要将数值数据存储到文本文件中,必须先使用函数 str() 将其转换为字符串格式。

10.2.2 写入多行

write_text() 方法会在幕后完成几项工作。首先,如果 path 变量对应的路径指向的文件不存在,就创建它。其次,将字符串写入文件后,它会确保文件得以妥善地关闭。如果没有妥善地关闭文件,可能会导致数据丢失或受损。

要将多行写入文件,需要先创建一个字符串(其中包含要写入文件的全部内容),再调用 write_text() 并将这个字符串传递给它。下面将多行内容写入文件 programming.txt:

from pathlib import Path

contents = "I love programming.\n"
contents += "I love creating new games.\n"
contents += "I also love working with data.\n"

path = Path('programming.txt')
path.write_text(contents)

首先定义变量 contents,用于存储要写入文件的所有内容。接下来,使用运算符 += 在该变量中追加这个字符串。可根据需要执行这种操作任意多次,以创建任意长度的字符串。这里在每行末尾都添加了换行符,让每个句子都占一行。

如果你运行这个程序,再打开文件 programming.txt,将发现上述每一行都在这个文本文件中:

I love programming.
I love creating new games.
I also love working with data.

也可以通过添加空格、制表符和空行来设置输出的格式,就像处理基于终端的输出那样。对于字符串的长度没有任何限制。计算机生成的很多文件就是这样创建的。

注意:在对 path 对象调用 write_text() 方法时,务必谨慎。如果指定的文件已存在, write_text() 将删除其内容,并将指定的内容写入其中。本章后面将介绍如何使用 pathlib 检查指定的文件是否存在。

动手试一试

练习 10.4:访客 编写一个程序,提示用户输入其名字。在用户做出响应后,将其名字写入文件 guest.txt。

练习 10.5:访客簿 编写一个 while 循环,提示用户输入其名字。收集用户输入的所有名字,将其写入 guest_book.txt,并确保这个文件中的每条记录都独占一行。

10.3 异常

Python 使用称为异常(exception)的特殊对象来管理程序执行期间发生的错误。每当发生让 Python 不知所措的错误时,它都会创建一个异常对象。如果你编写了处理该异常的代码,程序将继续运行;如果你未对异常进行处理,程序将停止,并显示一个 traceback,其中包含有关异常的报告。

异常是使用 try-except 代码块处理的。try-except 代码块让 Python 执行指定的操作,同时告诉 Python 在发生异常时应该怎么办。在使用 try-except 代码块时,即便出现异常,程序也将继续运行:显示你编写的友好的错误消息,而不是令用户迷惑的 traceback。

10.3.1 处理 ZeroDivisionError 异常

下面来看一种导致 Python 引发异常的简单错误。你可能知道不能将数除以 0,但还是让 Python 试试看吧:

division_calculator.py

print(5/0)

Python 无法这样做,因此你将看到一个 traceback:

Traceback (most recent call last):
    File "division_calculator.py", line 1, in <module>
      print(5/0)
            ~^~
❶ ZeroDivisionError: division by zero

在上述 traceback 中,错误 ZeroDivisionError 是个异常对象(见❶)。Python 在无法按你的要求做时,就会创建这种对象。在这种情况下,Python 将停止运行程序,并指出引发了哪种异常,而我们可根据这些信息对程序进行修改。下面将告诉 Python,在发生这种错误时该怎么办。这样,如果再次发生这样的错误,我们就有所准备了。

10.3.2 使用 try-except 代码块

当你认为可能发生错误时,可编写一个 try-except 代码块来处理可能引发的异常。你让 Python 尝试运行特定的代码,并告诉它如果这些代码引发了指定的异常,该怎么办。

处理 ZeroDivisionError 异常的 try-except 代码块类似于下面这样:

try:
    print(5/0)
except ZeroDivisionError:
    print("You can't divide by zero!")

这里将导致错误的代码行 print(5/0) 放在一个 try 代码块中。如果 try 代码块中的代码运行起来没有问题,Python 将跳过 except 代码块;如果 try 代码块中的代码导致错误,Python 将查找与之匹配的 except 代码块并运行其中的代码。

在这个示例中,try 代码块中的代码引发了 ZeroDivisionError 异常,因此 Python 查找指出了该怎么办的 except 代码块,并运行其中的代码。这样,用户看到的是一条友好的错误消息,而不是 traceback:

You can’t divide by zero!
如果 try-except 代码块后面还有其他代码,程序将继续运行,因为 Python 已经知道了如何处理错误。下面来看一个在捕获错误后让程序继续运行的示例。

10.3.3 使用异常避免崩溃

如果在错误发生时,程序还有工作没有完成,妥善地处理错误就显得尤其重要。这种情况经常出现在要求用户提供输入的程序中。如果程序能够妥善地处理无效输入,就能提示用户提供有效输入,而不至于崩溃。

下面来创建一个只执行除法运算的简单计算器:

division_calculator.py

 print("Give me two numbers, and I'll divide them.")
  print("Enter 'q' to quit.")

  while True:
❶     first_number = input("\nFirst number: ")
      if first_number == 'q':
          break
❷     second_number = input("Second number: ")
      if second_number == 'q':
          break
❸     answer = int(first_number) / int(second_number)
      print(answer)

在❶处,程序提示用户输入一个数,并将其赋给变量 first_number。如果用户输入的不是表示退出的 q,就再提示用户输入一个数,并将其赋给变量 second_number(见❷)。接下来,计算这两个数的商(见❸)。这个程序没有采取任何处理错误的措施,因此在执行除数为 0 的除法运算时,它将崩溃:

Give me two numbers, and I'll divide them.
Enter 'q' to quit.

First number: 5
Second number: 0
Traceback (most recent call last):
  File "division_calculator.py", line 11, in <module>
    answer = int(first_number) / int(second_number)
             ~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~
ZeroDivisionError: division by zero

程序崩溃可不好,让用户看到 traceback 也不是个好主意。不懂技术的用户会感到糊涂,怀有恶意的用户还能通过 traceback 获悉你不想让他们知道的信息。例如,他们将知道你的程序文件的名称,还将看到部分不能正确运行的代码。有时候,训练有素的攻击者可根据这些信息判断出可对你的代码发起什么样的攻击。

10.3.4 else 代码块

通过将可能引发错误的代码放在 try-except 代码块中,可提高程序抵御错误的能力。因为错误是执行除法运算的代码行导致的,所以需要将它放到 try-except 代码块中。这个示例还包含一个 else 代码块,只有 try 代码块成功执行才需要继续执行的代码,都应放到 else 代码块中:

  --snip--
  while True:
      --snip--
      if second_number == 'q':
          breaktry:
          answer = int(first_number) / int(second_number)except ZeroDivisionError:
          print("You can't divide by 0!")else:
          print(answer)

我们让 Python 尝试执行 try 代码块中的除法运算(见❶),这个代码块只包含可能导致错误的代码。依赖 try 代码块成功执行的代码都被放在 else 代码块中。在这个示例中,如果除法运算成功,就使用 else 代码块来打印结果(见❸)。

except 代码块告诉 Python,在出现 ZeroDivisionError 异常时该怎么办(见❷)。如果 try 代码块因零除错误而失败,就打印一条友好的消息,告诉用户如何避免这种错误。程序会继续运行,而用户根本看不到 traceback:

Give me two numbers, and I'll divide them.
Enter 'q' to quit.

First number: 5
Second number: 0
You can't divide by 0!

First number: 5
Second number: 2
2.5

First number: q

只有可能引发异常的代码才需要放在 try 语句中。有时候,有一些仅在 try 代码块成功执行时才需要运行的代码,这些代码应放在 else 代码块中。except 代码块告诉 Python,如果在尝试运行 try 代码块中的代码时引发了指定的异常该怎么办。

通过预测可能发生错误的代码,可编写稳健的程序。它们即便面临无效数据或缺少资源,也能继续运行,不受无意的用户错误和恶意攻击的影响。

10.3.5 处理 FileNotFoundError 异常

在使用文件时,一种常见的问题是找不到文件:要查找的文件可能在其他地方,文件名可能不正确,或者这个文件根本就不存在。对于所有这些情况,都可使用 try-except 代码块来处理。

我们来尝试读取一个不存在的文件。下面的程序尝试读取文件 alice.txt 的内容,但这个文件并没有被存储在 alice.py 所在的目录中:

alice.py

from pathlib import Path

path = Path('alice.txt')
contents = path.read_text(encoding='utf-8')

请注意,这里使用 read_text() 的方式与前面稍有不同。如果系统的默认编码与要读取的文件的编码不一致,参数 encoding 必不可少。如果要读取的文件不是在你的系统中创建的,这种情况更容易发生。

Python 无法读取不存在的文件,因此引发了一个异常:

Traceback (most recent call last):
❶   File "alice.py", line 4, in <module>
❷     contents = path.read_text(encoding='utf-8')
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    File "/.../pathlib.py", line 1056, in read_text
      with self.open(mode='r', encoding=encoding, errors=errors) as f:
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    File "/.../pathlib.py", line 1042, in open
      return io.open(self, mode, buffering, encoding, errors, newline)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
❸ FileNotFoundError: [Errno 2] No such file or directory: 'alice.txt'

这里的 traceback 比前面的那些都长,因此下面介绍如何看懂复杂的 traceback。通常最好从 traceback 的末尾着手。从最后一行可知,引发了异常 FileNotFoundError(见❸)。这一点很重要,它让我们知道应该在要编写的 except 代码块中使用哪种异常。

回头看看 traceback 开头附近(见❶),从这里可知,错误发生在文件 alice.py 的第四行。接下来的一行列出了导致错误的代码行(见❷)。traceback 的其余部分列出了一些代码,它们来自打开和读取文件涉及的库。通常,不需要详细阅读和理解 traceback 中的这些内容。

为了处理这个异常,应将 traceback 指出的存在问题的代码行放到 try 代码块中。这里,存在问题的是包含 read_text() 的代码行:

 from pathlib import Path

  path = Path('alice.txt')
  try:
      contents = path.read_text(encoding='utf-8')except FileNotFoundError:
      print(f"Sorry, the file {path} does not exist.")

在这个示例中,try 代码块中的代码引发了 FileNotFoundError 异常,因此要编写一个与该异常匹配的 except 代码块(见❶)。这样,当找不到文件时,Python 将运行 except 代码块中的代码,从而显示一条友好的错误消息,而不是 traceback:

Sorry, the file alice.txt does not exist.

如果文件不存在,这个程序就什么也做不了,因此上面就是这个程序的全部输出。下面来扩展这个示例,看看当你使用多个文件时,异常处理可提供什么样的帮助。

10.3.6 分析文本

你可以分析包含整本书的文本文件。很多经典文学作品是以简单的文本文件的方式提供的,因为它们不受版权限制。本节使用的文本来自古登堡计划,该计划提供了一系列不受版权限制的文学作品。如果你要在编程项目中使用文学文本,这是一个很不错的资源。

下面来提取童话 Alice in Wonderland(《爱丽丝漫游奇境记》)的文本,并尝试计算它包含多少个单词。我们将使用 split() 方法,它默认以空白为分隔符将字符串分拆成多个部分:

from pathlib import Path

  path = Path('alice.txt')
  try:
      contents = path.read_text(encoding='utf-8')
  except FileNotFoundError:
      print(f"Sorry, the file {path} does not exist.")
  else:
      #计算文件大致包含多少个单词
❶     words = contents.split()
❷     num_words = len(words)
      print(f"The file {path} has about {num_words} words.")

我将文件 alice.txt 移到了正确的目录中,让 try 代码块能够成功地执行。对变量 contents(它现在是一个长长的字符串,包含童话 Alice in Wonderland 的全部文本)调用 split() 方法,生成一个列表,其中包含这部童话中的所有单词(见❶)。通过对这个列表调用 len(),可知道原始字符串大致包含多少个单词(见❷)。最后,打印一条消息,指出文件包含多少个单词。这些代码都放在 else 代码块中,因为仅当 try 代码块成功执行时才会执行它们。输出指出了文件 alice.txt 包含多少个单词:

The file alice.txt has about 29594 words.

这个数略微偏大,因为这里使用的文本文件包含出版商提供的额外信息,但它与童话 Alice in Wonderland 的长度基本一致。

10.3.7 使用多个文件

下面多分析几本书。先将这个程序的大部分代码移到一个名为 count_words() 的函数中,这样对多本书进行分析会更容易:

word_count.py

 from pathlib import Path

  def count_words(path):"""计算一个文件大致包含多少个单词"""
    try:
          contents = path.read_text(encoding='utf-8')
      except FileNotFoundError:
          print(f"Sorry, the file {path} does not exist.")
      else:
          # 计算文件大致包含多少个单词
          words = contents.split()
          num_words = len(words)
          print(f"The file {path} has about {num_words} words.")

  path = Path('alice.txt')
  count_words(path)

这些代码大多与原来一样,只是被移到了函数 count_words() 中,并且增加了缩进量。在修改程序的同时更新注释是个不错的习惯,因此我们将注释改成了文档字符串,并稍微调整了一下措辞(见❶)。

现在可以编写一个简短的循环,计算要分析的任何文本包含多少个单词了。为此,我们把要分析的文件的名称存储在一个列表中,然后对列表中的每个文件都调用 count_words()。我们将尝试计算 Alice in Wonderland、Siddhartha(《悉达多》)、Moby Dick(《白鲸》)和 Little Women(《小妇人》)分别包含多少个单词,它们都不受版权限制。我故意没有将 siddhartha.txt 放到 word_count.py 所在的目录中,以便展示这个程序在文件不存在时应对得如何:

 from pathlib import Path

  def count_words(filename):
      --snip--

  filenames = ['alice.txt', 'siddhartha.txt', 'moby_dick.txt', 
          'little_women.txt']
  for filename in filenames:
❶     path = Path(filename)
      count_words(path)

先将文件名存储为简单字符串,然后将每个字符串转换为 Path 对象(见❶),再调用 count_words()。虽然文件 siddhartha.txt 不存在,但这丝毫不影响这个程序处理其他文件:

The file alice.txt has about 29594 words.
Sorry, the file siddhartha.txt does not exist.
The file moby_dick.txt has about 215864 words.
The file little_women.txt has about 189142 words.

在这个示例中,使用 try-except 代码块有两个重要的优点:一是避免用户看到 traceback,二是让程序可以继续分析能够找到的其他文件。如果不捕获因找不到 siddhartha.txt 而引发的 FileNotFoundError 异常,用户将看到完整的 traceback,而程序将在尝试分析 Siddhartha 后停止运行——根本不分析 Moby Dick 和 Little Women。

10.3.8 静默失败

在上一个示例中,我们告诉用户有一个文件找不到。但并非每次捕获异常都需要告诉用户,你有时候希望程序在发生异常时保持静默,就像什么都没有发生一样继续运行。要让程序静默失败,可像通常那样编写 try 代码块,但在 except 代码块中明确地告诉 Python 什么都不要做。Python 有一个 pass 语句,可在代码块中使用它来让 Python 什么都不做:

def count_words(path):
    """计算一个文件大致包含多少个单词"""
    try:
        --snip--
    except FileNotFoundError:
        pass
    else:
        --snip--

相比于上一个程序,这个程序唯一的不同之处是,except 代码块包含一条 pass 语句。现在,当出现 FileNotFoundError 异常时,虽然仍将执行 except 代码块中的代码,但什么都不会发生。当这种错误发生时,既不会出现 traceback,也没有任何输出。用户将看到存在的每个文件包含多少个单词,但没有任何迹象表明有一个文件未找到:

The file alice.txt has about 29594 words.
The file moby_dick.txt has about 215864 words.
The file little_women.txt has about 189142 words.

pass 语句还充当了占位符,提醒你在程序的某个地方什么都没有做,而且以后也许要在这里做些什么。例如,在这个程序中,我们可能决定将找不到的文件的名称写入文件 missing_files.txt。虽然用户看不到这个文件,但我们可以读取它,进而处理所有找不到文件的问题。

10.3.9 决定报告哪些错误

该在什么情况下向用户报告错误?又该在什么情况下静默失败呢?如果用户知道要分析哪些文件,他们可能希望在有文件未被分析时出现一条消息来告知原因。如果用户只想看到结果,并不知道要分析哪些文件,可能就无须在有些文件不存在时告知他们。向用户显示他们不想看到的信息可能会降低程序的可用性。Python 的错误处理结构让你能够细致地控制与用户共享错误信息的程度,要共享多少信息由你决定。

编写得很好且经过恰当测试的代码不容易出现内部错误,如语法错误和逻辑错误,但只要程序依赖于外部因素,如用户输入、是否存在指定的文件、是否有网络连接,就有可能出现异常。凭借经验可判断该在程序的什么地方包含异常处理块,以及出现错误时该向用户提供多少相关的信息。

动手试一试

练习 10.6:加法运算 在提示用户提供数值输入时,常出现的一个问题是,用户提供的是文本而不是数。在这种情况下,当你尝试将输入转换为整数时,将引发 ValueError 异常。编写一个程序,提示用户输入两个数,再将它们相加并打印结果。在用户输入的任意一个值不是数时都捕获 ValueError 异常,并打印一条友好的错误消息。对你编写的程序进行测试:先输入两个数,再输入一些文本而不是数。

练习 10.7:加法计算器 将为练习 10.6 编写的代码放在一个 while 循环中,让用户在犯错(输入的是文本而不是数)后能够继续输入数。

练习 10.8:猫和狗 创建文件 cats.txt 和 dogs.txt,在第一个文件中至少存储三只猫的名字,在第二个文件中至少存储三条狗的名字。编写一个程序,尝试读取这些文件,并将其内容打印到屏幕上。将这些代码放在一个 try-except 代码块中,以便在文件不存在时捕获 FileNotFoundError 异常,并显示一条友好的消息。将任意一个文件移到另一个地方,并确认 except 代码块中的代码将正确地执行。

练习 10.9:静默的猫和狗 修改你在练习 10.8 中编写的 except 代码块,让程序在文件不存在时静默失败。

练习 10.10:常见单词 访问古登堡计划,找一些你想分析的图书。下载这些作品的文本文件或将浏览器中的原始文本复制到文本文件中。

可以使用方法 count() 来确定特定的单词或短语在字符串中出现了多少次。例如,下面的代码计算 ‘row’ 在一个字符串中出现了多少次:

>>> line = "Row, row, row your boat"
>>> line.count('row')
2
>>> line.lower().count('row')
3

请注意,通过使用 lower() 将字符串转换为全小写的,可捕捉要查找的单词的各种格式,而不管其大小写如何。

编写一个程序,读取你在古登堡计划中获取的文件,并计算单词 ‘the’ 在每个文件中分别出现了多少次。这里计算得到的结果并不准确,因为诸如 ‘then’ 和 ‘there’ 等单词也被计算在内了。请尝试计算 'the '(包含空格)出现的次数,看看结果相差多少。

10.4 存储数据

很多程序要求用户输入某种信息,比如让用户存储游戏首选项或提供要可视化的数据。不管专注点是什么,程序都会把用户提供的信息存储在列表和字典等数据结构中。当用户关闭程序时,几乎总是要保存他们提供的信息。一种简单的方式是使用模块 json 来存储数据。

模块 json 让你能够将简单的 Python 数据结构转换为 JSON 格式的字符串,并在程序再次运行时从文件中加载数据。你还可以使用 json 在 Python 程序之间共享数据。更重要的是,JSON 数据格式并不是 Python 专用的,这让你能够将以 JSON 格式存储的数据与使用其他编程语言的人共享。这是一种轻量级数据格式,不仅很有用,也易于学习。

注意:JSON(JavaScript Object Notation)格式最初是为 JavaScript 开发的,但随后成了一种通用的格式,被包括 Python 在内的众多语言采用。

10.4.1 使用 json.dumps() 和 json.loads()

下面先编写一个存储一组数的简短程序,再编写一个将这些数读取到内存中的程序。第一个程序将使用 json.dumps() 来存储这组数,而第二个程序将使用 json.loads() 来读取它们。

json.dumps() 函数接受一个实参,即要转换为 JSON 格式的数据。这个函数返回一个字符串,这样你就可将其写入数据文件了:

number_writer.py

  from pathlib import Path
  import json

  numbers = [2, 3, 5, 7, 11, 13]

❶ path = Path('numbers.json')
❷ contents = json.dumps(numbers)
  path.write_text(contents)

首先导入模块 json,并创建一个数值列表。然后选择一个文件名,指定要将该数值列表存储到哪个文件中(见❶)。通常使用文件扩展名 .json 来指出文件存储的数据为 JSON 格式。接下来,使用 json.dumps() 函数生成一个字符串(见❷),它包含我们要存储的数据的 JSON 表示形式。生成这个字符串后,像本章前面一样,使用 write_text() 方法将其写入文件。

这个程序没有输出,我们打开文件 numbers.json 一探究竟。该文件中数据的存储格式看起来与 Python 中一样:

[2, 3, 5, 7, 11, 13]

下面再编写一个程序,使用 json.loads() 将这个列表读取到内存中:

number_reader.py

 from pathlib import Path
  import json

❶ path = Path('numbers.json')
❷ contents = path.read_text()
❸ numbers = json.loads(contents)

  print(numbers)

在❶处,确保读取的是前面写入的文件。这个数据文件是使用特殊格式的文本文件,因此可使用 read_text() 方法来读取它(见❷)。然后将这个文件的内容传递给 json.loads()(见❸)。这个函数将一个 JSON 格式的字符串作为参数,并返回一个 Python 对象(这里是一个列表),而我们将这个对象赋给了变量 numbers。最后,打印恢复的数值列表,看看是否与 number_writer.py 中创建的数值列表相同:

[2, 3, 5, 7, 11, 13]

这是一种在程序之间共享数据的简单方式。

10.4.2 保存和读取用户生成的数据

使用 json 保存用户生成的数据很有必要,因为如果不以某种方式进行存储,用户的信息就会在程序停止运行时丢失。下面来看一个这样的例子:提示用户在首次运行程序时输入自己的名字,并且在他再次运行程序时仍然记得他。

先来存储用户的名字:

remember_me.py

 from pathlib import Path
  import json

❶ username = input("What is your name? ")

❷ path = Path('username.json')
  contents = json.dumps(username)
  path.write_text(contents)print(f"We'll remember you when you come back, {username}!")

首先,提示用户输入名字(见❶)。接下来,将收集到的数据写入文件 username.json(见❷)。然后,打印一条消息,指出存储了用户输入的信息(见❸):

What is your name? Eric
We’ll remember you when you come back, Eric!
现在再编写一个程序,向名字已被存储的用户发出问候:

greet_user.py

 from pathlib import Path
  import json

❶ path = Path('username.json')
  contents = path.read_text()
❷ username = json.loads(contents)

  print(f"Welcome back, {username}!")

我们读取数据文件的内容(见❶),并使用 json.loads() 将恢复的数据赋给变量 username(见❷)。有了已恢复的用户名,就可以使用个性化的问候语欢迎用户回来了:

Welcome back, Eric!

需要将这两个程序合并到一个程序(remember_me.py)中。在这个程序运行时,将尝试从内存中获取用户的用户名。如果没有找到,就提示用户输入用户名,并将其存储到文件 username.json 中,以供下次使用。这里原本可以编写一个 try-except 代码块,以便在文件 username.json 不存在时采取合适的措施,但我们没有这样做,而是使用了 pathlib 模块提供的一个便利方法:

remember_me.py

  from pathlib import Path
  import json

  path = Path('username.json')if path.exists():
      contents = path.read_text()
      username = json.loads(contents)
      print(f"Welcome back, {username}!")else:
      username = input("What is your name? ")
      contents = json.dumps(username)
      path.write_text(contents)
      print(f"We'll remember you when you come back, {username}!")

Path 类提供了很多很有用的方法。如果指定的文件或文件夹存在,exists() 方法返回 True,否则返回 False。这里使用 path.exists() 来确定是否存储了用户名(见❶)。如果文件 username.json 存在,就加载其中的用户名,并向用户发出个性化问候。

如果文件 username.json 不存在(见❷),就提示用户输入用户名,并存储用户输入的值。此外,还会打印一条消息,指出当用户再回来时我们还会记得他。

无论执行的是哪个代码块,都将显示用户名和合适的问候语。如果这是程序首次运行,输出将如下所示:

What is your name? Eric
We'll remember you when you come back, Eric!

否则,输出将如下所示:

Welcome back, Eric!

这是程序之前至少运行了一次时的输出。虽然这里存储的数据只是单个字符串,但这个程序可处理所有可转换为 JSON 格式字符串的数据。

10.4.3 重构

你经常会遇到这样的情况:虽然代码能够正确地运行,但还可以将其划分为一系列完成具体工作的函数来进行改进。这样的过程称为重构。重构让代码更清晰、更易于理解、更容易扩展。

要重构 remember_me.py,可将其大部分逻辑放到一个或多个函数中。remember_me.py 的重点是问候用户,因此将其所有代码都放到一个名为 greet_user() 的函数中:

remember_me.py

 from pathlib import Path
  import json

  def greet_user():"""问候用户,并指出其名字"""
      path = Path('username.json')
      if path.exists():
          contents = path.read_text()
          username = json.loads(contents)
          print(f"Welcome back, {username}!")
      else:
          username = input("What is your name? ")
          contents = json.dumps(username)
          path.write_text(contents)
          print(f"We'll remember you when you come back, {username}!")


  greet_user()

考虑到现在使用了一个函数,我们删除注释,转而使用一个文档字符串来指出程序的作用(见❶)。这个程序更加清晰,但 greet_user() 函数所做的不仅是问候用户,还在存储了用户名时获取它,在没有存储用户名时提示用户输入。

下面重构 greet_user(),不让它执行这么多任务。首先将获取已存储用户名的代码移到另一个函数中:

 from pathlib import Path
  import json

  def get_stored_username(path):"""如果存储了用户名,就获取它"""
      if path.exists():
          contents = path.read_text()
          username = json.loads(contents)
          return username
      else:return None

  def greet_user():
      """问候用户,并指出其名字"""
      path = Path('username.json')
      username = get_stored_username(path)if username:
          print(f"Welcome back, {username}!")
      else:
          username = input("What is your name? ")
          contents = json.dumps(username)
          path.write_text(contents)
          print(f"We'll remember you when you come back, {username}!")

  greet_user()

新增的 get_stored_username() 函数目标明确,文档字符串(见❶)指出了这一点。如果存储了用户名,就获取并返回它;如果传递给 get_stored_username() 的路径不存在,就返回 None(见❷)。这是一种不错的做法:函数要么返回预期的值,要么返回 None。这让我们能够使用函数的返回值做简单的测试。如果成功地获取了用户名(见❸),就打印一条欢迎用户回来的消息,否则提示用户输入用户名。

还需要将 greet_user() 中的另一个代码块提取出来,将在没有存储用户名时提示用户输入的代码放在一个独立的函数中:

  from pathlib import Path
  import json

  def get_stored_username(path):
      """如果存储了用户名,就获取它"""
      --snip--

  def get_new_username(path):
      """提示用户输入用户名"""
      username = input("What is your name? ")
      contents = json.dumps(username)
      path.write_text(contents)
      return username

  def greet_user():
      """问候用户,并指出其名字"""
      path = Path('username.json')
❶     username = get_stored_username(path)
      if username:
          print(f"Welcome back, {username}!")
      else:
❷         username = get_new_username(path)
          print(f"We'll remember you when you come back, {username}!")

  greet_user()

在 remember_me.py 的这个最终版本中,每个函数都执行单一而清晰的任务。我们调用 greet_user(),它打印一条合适的消息:要么欢迎老用户回来,要么问候新用户。为此,它首先调用 get_stored_username()(见❶),这个函数只负责获取已存储的用户名(如果存储了),再在必要时调用 get_new_username()(见❷),这个函数只负责获取并存储新用户的用户名。要编写出清晰且易于维护和扩展的代码,这种划分必不可少。

动手试一试

练习 10.11:喜欢的数 编写一个程序,提示用户输入自己喜欢的数,并使用 json.dumps() 将这个数存储在文件中。再编写一个程序,从文件中读取这个值,并打印如下消息。

I know your favorite number! It’s _____.

练习 10.12:记住喜欢的数 将你在完成练习 10.11 时编写的两个程序合而为一。如果存储了用户喜欢的数,就向用户显示它,否则提示用户输入自己喜欢的数并将其存储在文件中。运行这个程序两次,看看它是否像预期的那样工作。

练习 10.13:用户字典 示例 remember_me.py 只存储了一项信息——用户名。请扩展该示例,让用户同时提供另外两项信息,再将收集到的所有信息存储到一个字典中。使用 json.dumps() 将这个字典写入文件,并使用 json.loads() 从文件中读取它。打印一条摘要消息,指出程序记住了有关用户的哪些信息。

练习 10.14:验证用户 最后一个 remember_me.py 版本假设用户要么已输入其用户名,要么是首次运行该程序。我们应修改这个程序,以防当前用户并非上次运行该程序的用户。

为此,在 greet_user() 中打印欢迎用户回来的消息之前,询问他用户名是否是对的。如果不对,就调用 get_new_username() 让用户输入正确的用户名。

10.5 小结

在本章中,你首先学习了如何使用文件,包括如何读取整个文件,如何读取文件中的各行,以及如何根据需要将任意数量的文本写入文件。然后学习了异常,以及如何处理程序可能引发的异常。最后,你学习了如何存储 Python 数据结构,以保存用户提供的信息,避免让用户在每次运行程序时都重新提供。

在第 11 章中,你将学习高效的代码测试方式。这不仅能帮助你确定代码正确无误,还有助于发现扩展既有程序时可能引入的 bug。

  • 5
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值