AI中学生 CAICP 学习指南

2.4 模块、文档阅读与程序调试

导入已有工具

函数能把一项工作组织起来,如果相关函数很多,就可以进一步把它们放在一起,形成模块。模块中还可以保存常量和其他定义,供别的程序使用。Python 配备了一组常用工具,称为标准库;其中的 math 模块提供数学运算,random 提供随机工具,os 提供与操作系统有关的功能。这些模块可以直接使用,不需要另外编写它们的内部代码。要让当前程序访问这些工具,先用 import 导入模块。下面在 math 后加点号,调用其中的平方根函数 sqrt()。因为 5 的平方为 25,math.sqrt(25) 得到 5.0。

import math

print(math.sqrt(25))   # 5.0
print(math.floor(2.8)) # 2
print(math.ceil(2.2))  # 3

floor() 取不大于给定数的最大整数,ceil() 取不小于给定数的最小整数,所以对负数也应按数轴方向理解:math.floor(-2.2) 为 -3,math.ceil(-2.2) 为 -2。数学模块还提供圆周率 math.pi 等常量,即使用时作为固定数值看待的量。math.sqrt() 处理普通实数时不能直接给负数开平方,调用前仍要考虑输入条件。

另一种导入写法是 from math import sqrt,只把所需名称引入当前程序,随后直接写 sqrt(25)。import math as m 则给模块取别名,随后使用 m.sqrt(25)。本书初次使用模块时通常保留完整的模块名,便于看清函数来自哪里。变量、方法和模块都会用到点号,但点号前后的名称说明了正在访问谁的内容。

random 模块用于产生伪随机数,也就是通过确定的算法产生看起来具有随机变化特点的数值。它常用来模拟抽签、打乱数据或进行随机抽样。下面创建一个随机数生成器,再调用它的不同方法;注释说明结果应当满足的条件,不把某一次抽到的值当作永远不变的答案。

import random

rng = random.Random(7)
print(rng.randint(1, 6))        # 1 到 6 之间的整数,含两端
print(rng.choice(["甲", "乙", "丙"]))  # 从三项中选一项
print(rng.sample([1, 2, 3, 4], 2))   # 抽取两个不同位置

Random(7) 中的 7 是随机种子,用来确定生成器的初始状态。在相同实现、相同种子和相同调用顺序等条件下,通常可以重复得到相同序列,便于比较程序修改前后的结果。random() 产生大于等于 0、小于 1 的浮点数;randint(a, b) 的整数范围包含两个端点,不能与 range() 的终点规则混淆。sample() 不重复抽取同一个位置,但原序列若有重复值,抽出的值仍可能相同。shuffle() 在原列表中打乱顺序,返回 None。随机工具提供了数据变化,是否符合具体任务的抽样要求仍需要判断。

os 模块提供与操作系统打交道的功能,例如查看文件夹、组合路径和判断文件是否存在。路径用来说明文件或文件夹的位置;绝对路径从文件系统的起点定位,相对路径则以当前工作目录为出发点。当前工作目录不一定就是代码文件所在的目录。

import os

print(os.getcwd())
path = os.path.join("data", "records.txt")
print(os.path.isfile(path))

第一行输出随运行位置变化,最后一行输出 True 或 False,表示相应路径是否指向一个已存在的文件。os.path.join() 按系统规则把文件夹名和文件名组合成路径,os.listdir("data") 则列出文件夹内的条目名称。列出的名称不保证已经排序,需要固定顺序时可以使用 sorted()。处理一批图片或文本之前,通常就要先找到文件所在的位置。

读取文件内容可以用内置函数 open()。下面假定当前工作目录下已有 data 文件夹,其中有一个以 UTF-8 编码保存的 records.txt 文本文件。文字编码规定文字如何表示成字节,UTF-8 是一种常用编码方式;读取时应与保存方式相符。

import os

path = os.path.join("data", "records.txt")
with open(path, encoding="utf-8") as file:
    text = file.read()
print(text)

open() 返回一个表示已打开文件的对象,as file 把它交给变量 file,file.read() 读取其中的文字。with 使程序在离开这个代码块时关闭文件,不必再另写关闭语句。最后输出的是文件实际保存的内容,因此结果随文件而变。遇到“文件找不到”,应先核对路径和工作目录;程序并不知道一份资料在人脑中被归到了哪个文件夹。

从文档理解一个陌生函数

文档是工具的使用说明,写着用途、参数、返回结果和使用条件。程序员也经常查文档,不会把全部函数记在脑中。Python 可以用 help(str.split) 或导入后的 help(math.sqrt) 查看相应帮助。函数名和参数列表称为函数签名,从中能看出需要交给工具哪些信息。以 split(sep=None, maxsplit=-1) 为例,sep 指定分隔方式,maxsplit 限制分割次数,负数表示不限次数。对 "A:B:C" 使用 split(":", 1),只分割一次,便得到 ['A', 'B:C']。签名中的 / 表示前面的参数只能按位置传递,单独的 * 表示后面的参数必须写出名称;这些符号说明调用规则,通常不照抄进调用语句。再看返回值的类型、是否修改原对象、没有找到所需内容时怎样处理,就能逐步掌握一个陌生工具。CAICP 中给出文档提示的任务,也需要这样读懂说明再分析代码。

读一段陌生代码,可以先确认输入是什么,再逐步记录中间结果的类型、内容和形状。字符串经过 split() 变成列表,列表中的某一项经过 float() 变成数值,数值比较又产生布尔值。只要这些变化能连起来,即使中间使用了不熟悉的方法,也可以借助文档继续分析。遇到简短示例,应自己换一组小输入检查,尤其可以试一试没有匹配项、只有一项或刚好位于边界的情况。

让错误变得可以定位

程序没报错,并不等于结果正确。

漏写冒号或括号不配对,会造成语法错误,缩进出错也可能让程序无法开始执行。除以零、访问不存在的位置等运行错误,则发生在执行某条语句时。还有一种更隐蔽的逻辑错误:程序顺利结束,却没有完成原来的要求。例如,打算累加到 n,写的却是 range(1, n),就会漏掉最后一项。先分清错误属于哪一类,才知道从哪里查起。

查找并修正程序错误的过程称为调试。运行错误通常会给出回溯信息,列出出错的调用位置,并在末尾显示异常名称和原因。异常是程序执行中报告错误或特殊情况的一种机制。常见的 NameError 表示名字无法找到,TypeError 表示对象类型不适合当前操作,ValueError 表示值不符合要求,IndexError 表示序列索引超出范围,KeyError 表示字典中没有相应键。检查时先读末尾的异常说明,再找到自己代码中的相关行,并核对那一行使用的变量。

对于可以预料并有明确处理办法的异常,可以用 try-except。try 中写需要尝试的操作,except 后写要处理的异常类型,以及出现这种异常时执行的语句。程序先执行 try 的代码块,成功就跳过对应的 except;遇到指定异常,才转入处理部分。下面的转换会失败,因此输出一段说明,程序随后可以继续。

text = "3.5"
try:
    count = int(text)
except ValueError:
    print("这段文字不能直接转换为整数")

没有异常信息可看时,可以先用最小的一组数据,把预期结果与实际结果比较,再临时打印关键变量,找到第一个不符合预期的中间值。前面的求和程序,用 n = 1、n = 3 就容易查出是否漏了一项;换成大数,反而不便手算。空列表、只有一项、数值恰好等于阈值,都是边界情况,选择它们是为了检查分支与范围的含义。

代码也应让人容易看懂意图。名称说明用途,重复步骤可以提成函数,注释解释为什么这样处理。补全程序尤其要留意前后的约定:要求返回新列表,就不能只打印;要求保留顺序,也不能顺手用排序代替去重。

把几项基本操作连成一个程序

现在把几个基本操作连起来。假设一行文字按 "编号,长度,宽度" 保存一条记录,字段都齐全,彼此以英文逗号分隔。任务是把文字转成数值,保留长、宽都大于零的记录,再计算平均长度。可以先把读取一条记录的步骤写成 read_record 函数:用 split() 分字段,用 float() 转两个测量值,最后以元组返回三个结果。它接收的是已经放在字符串里的文字,这一步还不涉及打开硬盘上的文件。

def read_record(line):
    fields = line.split(",")
    code = fields[0].strip()
    length = float(fields[1])
    width = float(fields[2])
    return code, length, width

下面用循环逐条处理。total 记录有效长度的总和,count 记录有效条数。两者都放在循环外初始化,只有一条记录通过条件判断后才一起更新。程序最后才用总和除以条数。

lines = ["A,4.0,1.0", "B,-2.0,1.5", "C,8.0,3.0"]
total = 0.0
count = 0
for line in lines:
    code, length, width = read_record(line)
    if length > 0 and width > 0:
        total += length
        count += 1
if count > 0:
    print(total / count)  # 6.0
else:
    print("没有有效记录")

A 记录通过检查,使总和变为 4.0、条数变为 1。B 的长度为负,本例将它视为无效记录,因此总和和条数都不变。C 通过检查,最终总和为 12.0、条数为 2,平均长度是 6.0。如果误用 len(lines) 作为分母,得到的会是 4.0;程序依然能运行,含义却变成了把无效记录也计入数量。这就是一种逻辑错误。找出错误后,需要说明分母应统计什么,才能知道为什么应当修改。

如果一行文字写成 "D,很长,2.0",数值转换会失败;少一个字段,则可能在按索引访问时出错。怎样处理这些情况,应根据数据约定决定:是要求修正记录,还是跳过并保留原因。程序不能因为某一项读不懂,就擅自把它当成零。这里采用格式完整、测量值可转换的数据,是为了先看清几个基本步骤怎样配合;第五章会进一步讨论缺失、异常和数据清洗的不同含义。

若分隔符后来改成分号,只需集中修改读取函数。统计部分仍接收编号、长度、宽度;交接含义不变,两部分就可以分别调整。这也是把处理步骤写成函数的一个好处。

发现错误或有没讲清楚的地方?欢迎提交勘误与建议。请注明章节及原文。

↑