12 周课程 / 第 7 周 10 分钟阅读

第 7 周:文件读写与异常处理

程序中的数据不能只靠手工输入。本周学习从文本文件和 CSV 文件读取数据,把结果写到新文件,并让程序在文件不存在、格式错误等情况下给出清楚提示。

建议用时:8-10 小时。

本周目标#

学完本周,你应该能够:

  • 区分绝对路径、相对路径和当前工作目录。
  • 使用 with open() 安全打开文件。
  • 理解 r、w、a 三种模式的差异。
  • 使用 read()、readline()、readlines() 和逐行遍历。
  • 使用 write()、writelines() 和 print(..., file=f) 写文件。
  • 解析以固定分隔符组织的文本行。
  • 使用 csv.DictReader 读取 CSV。
  • 捕获常见异常,并输出可理解的错误信息。
  • 独立完成成绩文件到报告文件的转换。

7.1 路径与当前工作目录#

文件路径告诉程序到哪里找文件。

绝对路径#

从磁盘根目录开始,完整描述文件位置:

/Users/kukuaki/Desktop/study/py/模拟赛/data/stage2_scores.txt

换一台电脑或换一个用户目录后,这个路径可能不存在,所以程序不应随意写死个人电脑的绝对路径。

相对路径#

相对于当前工作目录的路径:

模拟赛/data/stage2_scores.txt

如果程序从 /Users/kukuaki/Desktop/study/py 运行,上面的路径能指向目标文件。如果从其他目录运行,同一行代码可能找不到文件。

“代码文件在哪”和“程序从哪运行”不总是同一件事。遇到 FileNotFoundError 时,先确认当前工作目录。

检查当前目录#

可以使用:

import os

print(os.getcwd())

本周先会读结果。第 8 周会系统学习 os.path.join()、os.makedirs() 等路径和目录操作。

7.2 with open() 的基本结构#

with open("data.txt", "r", encoding="utf-8") as file:
    content = file.read()

print(content)

参数含义:

  • "data.txt":文件路径。
  • "r":只读模式。
  • encoding="utf-8":按 UTF-8 读取文字。
  • file:文件对象名称,可以换成 f、source 等。

with 代码块结束时会自动关闭文件,即使中途发生异常也会执行关闭操作。比手工写 file.close() 更安全。

7.3 打开模式#

模式 含义 文件不存在时 文件已存在时
"r" 只读 报 FileNotFoundError 从头读取
"w" 只写 创建新文件 清空原内容
"a" 追加 创建新文件 在末尾继续写
"r+" 读写 报错 从开头读写
"x" 独占创建 创建文件 报 FileExistsError

初学阶段最常使用 "r"、"w"、"a"。

"w" 很危险:只要成功打开,原内容立即被清空。修改输入数据时,应写入新文件,例如:

input_path = "scores.txt"
output_path = "scores_report.txt"

不要为了省事把结果写回输入文件,除非题目明确要求并且已经备份。

7.4 一次读取全部内容:read()#

假设 hello.txt 内容是:

第一行
第二行
第三行

程序:

with open("hello.txt", "r", encoding="utf-8") as file:
    content = file.read()

print(content)

content 是一个包含换行符的字符串。读取后文件指针到达末尾,再次 read() 会得到空字符串:

with open("hello.txt", "r", encoding="utf-8") as file:
    first = file.read()
    second = file.read()

print(repr(first))
print(repr(second))

第二次读取结果为空,是因为读取位置已经在文件末尾。不是文件突然没了内容。

7.5 逐行读取#

readline()#

每次读取一行:

with open("hello.txt", "r", encoding="utf-8") as file:
    line1 = file.readline()
    line2 = file.readline()

print(repr(line1))
print(repr(line2))

每行通常保留末尾换行符。清理两端空白:

clean_line = line1.strip()

readlines()#

一次读取所有行,返回字符串列表:

with open("hello.txt", "r", encoding="utf-8") as file:
    lines = file.readlines()

print(lines)
# ['第一行\n', '第二行\n', '第三行']

直接遍历文件#

这是处理大文件最常用的形式:

with open("hello.txt", "r", encoding="utf-8") as file:
    for line in file:
        clean_line = line.strip()
        if clean_line:
            print(clean_line)

它每次只加载一行,不必一次把所有内容放进内存。

7.6 写文本文件#

写入新文件#

with open("output.txt", "w", encoding="utf-8") as file:
    file.write("第一行\n")
    file.write("第二行\n")

write() 不会自动添加换行,必须自己写 \n。

自动换行:print(..., file=...)#

with open("output.txt", "w", encoding="utf-8") as file:
    print("第一行", file=file)
    print("第二行", file=file)

print() 默认在末尾添加换行,并把内容写到指定文件对象。

写入多行:writelines()#

lines = ["第一行\n", "第二行\n", "第三行\n"]

with open("output.txt", "w", encoding="utf-8") as file:
    file.writelines(lines)

writelines() 不会自动为每个字符串加换行,列表元素必须自己带 \n,或者提前处理。

追加内容#

with open("output.txt", "a", encoding="utf-8") as file:
    file.write("追加的内容\n")

使用 "a" 不会清空原文件。

7.7 解析固定格式文本#

第二阶段成绩文件的每行格式:

学号|姓名|班级|信息科技|数学|英语

示例:

S001|张晨|高一(1)班|86|91|78
S002|李雨|高一(1)班|92|88|90

读取一行并拆分:

line = "S001|张晨|高一(1)班|86|91|78"
parts = line.strip().split("|")

student_id = parts[0]
name = parts[1]
class_name = parts[2]
scores = [int(parts[3]), int(parts[4]), int(parts[5])]

print(student_id, name, class_name, scores)

为什么先 strip()#

strip() 去掉行末换行符和两端空白,避免最后一列变成 "78\n"。虽然 int("78\n") 通常也能工作,但先清理能让字段比较和输出更稳定。

为什么先检查字段数#

parts = line.strip().split("|")

if len(parts) != 6:
    print("字段数量不正确:", len(parts))
else:
    # 继续处理
    pass

如果某一行缺字段,直接访问 parts[5] 会报 IndexError。好的数据处理程序要能指出哪一行格式有问题。

7.8 读取 CSV#

CSV 是用逗号分隔字段的文本文件。第三阶段的输入 CSV 表头是:

reader_id,name,class_name,category,borrow_count,overdue_count

数据示例:

R001,林晓,高一(1)班,文学,8,1
R002,韩梅,高一(1)班,科技,4,0

使用 csv.DictReader:

import csv

with open("readers.csv", "r", encoding="utf-8", newline="") as file:
    reader = csv.DictReader(file)

    for row in reader:
        print(row["reader_id"], row["name"])
        print(row["borrow_count"], type(row["borrow_count"]).__name__)

要点:

  • DictReader 使用第一行表头作为键。
  • 每一行得到字典。
  • CSV 读出来的所有值都是字符串,数字仍要 int()。
  • 读取 CSV 时建议加 newline="",让 csv 模块正确处理换行。

转换数字:

borrow_count = int(row["borrow_count"])
overdue_count = int(row["overdue_count"])

按位置读取:csv.reader#

import csv

with open("readers.csv", "r", encoding="utf-8", newline="") as file:
    reader = csv.reader(file)
    header = next(reader)

    for row in reader:
        print(row)

next(reader) 取到表头,后续循环从第一行数据开始。字段多或题目标明列名时,DictReader 更不容易把列顺序记错。

7.9 异常处理#

文件程序最容易遇到:

异常 典型原因
FileNotFoundError 文件路径不存在
PermissionError 没有读写权限
UnicodeDecodeError 文件编码与声明不一致
ValueError int() 收到非数字
IndexError 字段不足却按固定索引访问
KeyError CSV 表头缺少需要的列

基础结构:

try:
    with open("data.txt", "r", encoding="utf-8") as file:
        content = file.read()
except FileNotFoundError:
    print("错误:没有找到 data.txt")
except PermissionError:
    print("错误:没有权限读取 data.txt")

程序仍然继续执行后面的代码,而不是直接终止。

捕获异常不代表隐藏错误#

不推荐:

try:
    score = int(text)
except Exception:
    pass

这会吞掉所有错误,连变量名写错也可能被掩盖。应该只捕获预期异常,并给出可理解提示:

try:
    score = int(text)
except ValueError:
    print(f"错误:{text!r} 不是整数")
    score = None

{text!r} 会用引号显示原值,方便定位空格或隐藏字符。

else 与 finally#

try:
    number = int("12")
except ValueError:
    print("转换失败")
else:
    print("转换成功:", number)
finally:
    print("无论成功还是失败都会执行")

基础文件题通常使用 with 加 try/except 就足够,不必为了“高级”强行加入 finally。

7.10 读入、处理、写出#

处理文件的推荐顺序:

  1. 明确输入路径和输出路径。
  2. 读取并解析数据。
  3. 在内存中完成计算。
  4. 检查结果数量和字段格式。
  5. 打开输出文件写入。
  6. 重新读取输出文件或用编辑器检查。

不要一边逐行读取,一边随意覆盖输入文件。保持“输入只读、输出另存”更容易验证。

7.11 本周作品:成绩报告生成器#

需求#

读取:

模拟赛/data/stage2_scores.txt

每行包含学号、姓名、班级和三科成绩。要求:

  • 每个人输出一行:学号 姓名 总分。
  • 按班级第一次出现的顺序输出平均分,保留两位小数。
  • 输出最高总分学生;并列时取文件中先出现者。

完整参考程序#

input_path = "模拟赛/data/stage2_scores.txt"
output_path = "模拟赛/output/第二阶段_成绩报告.txt"

students = []
class_totals = {}

try:
    with open(input_path, "r", encoding="utf-8") as source:
        for line_number, line in enumerate(source, start=1):
            clean_line = line.strip()
            if not clean_line:
                continue

            parts = clean_line.split("|")
            if len(parts) != 6:
                raise ValueError(
                    f"第 {line_number} 行应有 6 个字段,实际有 {len(parts)} 个"
                )

            student_id, name, class_name = parts[:3]
            scores = [int(parts[3]), int(parts[4]), int(parts[5])]
            total = sum(scores)

            student = {
                "student_id": student_id,
                "name": name,
                "class_name": class_name,
                "total": total,
            }
            students.append(student)

            if class_name not in class_totals:
                class_totals[class_name] = {"total": 0, "count": 0}
            class_totals[class_name]["total"] += total
            class_totals[class_name]["count"] += 1

    if not students:
        raise ValueError("输入文件中没有学生数据")

    highest_student = students[0]
    for student in students[1:]:
        if student["total"] > highest_student["total"]:
            highest_student = student

    with open(output_path, "w", encoding="utf-8") as target:
        for student in students:
            print(
                student["student_id"],
                student["name"],
                student["total"],
                file=target,
            )

        print("班级平均分", file=target)
        for class_name, data in class_totals.items():
            average = data["total"] / data["count"]
            print(f"{class_name} {average:.2f}", file=target)

        print(
            "最高总分:"
            f"{highest_student['student_id']} "
            f"{highest_student['name']} "
            f"{highest_student['total']}",
            file=target,
        )

    print("报告已生成:", output_path)

except FileNotFoundError:
    print("错误:找不到输入文件", input_path)
except ValueError as error:
    print("数据处理错误:", error)
except PermissionError:
    print("错误:没有权限读取或写入文件")

运行前检查#

  • 当前工作目录是否是 study/py。
  • 模拟赛/output/ 目录是否存在。
  • 输入文件编码是否为 UTF-8。
  • 输入每行是否有 6 个字段。

路径和目录创建会在第 8 周进一步强化。若输出目录不存在,open(..., "w") 会报 FileNotFoundError,它不会自动创建多层目录。

验证输出#

报告核心内容应与参考答案一致。另做以下测试:

测试 修改内容 期望
正常数据 原文件 生成完整报告
空文件 所有行删除 提示没有学生数据
缺字段 某行少一列 指出具体行号
非数字 某科成绩写成 ABC 提示数据处理错误
最高分并列 两名学生总分相同 输出先出现者

7.12 独立练习#

A. 快速判断题#

  1. "r" 和 "w" 的主要区别是什么?
  2. 打开已存在文件使用 "w" 会发生什么?
  3. with open() 代码块结束后会自动做什么?
  4. read() 和 readlines() 返回类型分别是什么?
  5. write("abc") 会自动换行吗?
  6. 文件每行末尾的 \n 怎样去除?
  7. CSV 经 DictReader 读取后,数字字段的类型默认是什么?
  8. FileNotFoundError 通常说明什么?
  9. 为什么不应该直接捕获 Exception 后 pass?
  10. 为什么修改工作簿或数据文件时应输出到新文件?

B. 编程题#

  1. 创建 names.txt,写入三个姓名,每行一个。
  2. 读取 names.txt,输出去掉行末换行后的姓名。
  3. 统计一个文本文件有多少非空行。
  4. 读取三行整数,计算总和并写入新文件。
  5. 读取一份用 | 分隔的成绩文件,输出每个人总分。
  6. 用 csv.DictReader 读取借阅数据,输出每个读者姓名和借阅次数。
  7. 文件不存在时给出清楚提示,而不是直接终止。
  8. 把一组结果列表写入文本文件,每行一项。

7.13 练习答案与解释#

A. 快速判断题答案#

  1. "r" 只读;"w" 写入且会清空已有内容。
  2. 原内容立即被清空,文件变为空文件。
  3. 自动关闭文件。
  4. read() 返回字符串;readlines() 返回字符串列表。
  5. 不会,需要自行在字符串中加 \n,或使用 print(..., file=f)。
  6. 使用 strip(),或只想移除换行时使用 rstrip("\n")。
  7. str。
  8. 指定路径的文件不存在,或路径写法、当前工作目录不对。
  9. 它会隐藏程序错误,导致后续得到错误结果却不知道原因。
  10. 避免清空或损坏原始数据,便于对比和重新测试。

B. 编程题参考答案#

  1. 写入姓名:

    with open("names.txt", "w", encoding="utf-8") as file:
        file.write("张晨\n")
        file.write("李雨\n")
        file.write("王浩\n")
    
  2. 读取姓名:

    with open("names.txt", "r", encoding="utf-8") as file:
        for line in file:
            print(line.strip())
    
  3. 统计非空行:

    count = 0
    with open("data.txt", "r", encoding="utf-8") as file:
        for line in file:
            if line.strip():
                count += 1
    print(count)
    
  4. 三行整数求和:

    total = 0
    with open("numbers.txt", "r", encoding="utf-8") as file:
        for line in file:
            total += int(line.strip())
    
    with open("sum.txt", "w", encoding="utf-8") as file:
        print(total, file=file)
    
  5. 成绩总分:

    with open("scores.txt", "r", encoding="utf-8") as file:
        for line in file:
            parts = line.strip().split("|")
            if len(parts) == 6:
                total = sum(int(value) for value in parts[3:])
                print(parts[0], parts[1], total)
    

    这里使用了生成器表达式。若还不熟,可以写显式循环逐个转换。

  6. 读取借阅次数:

    import csv
    
    with open(
        "模拟赛/data/stage3_borrowings.csv",
        "r",
        encoding="utf-8",
        newline="",
    ) as file:
        reader = csv.DictReader(file)
        for row in reader:
            print(row["name"], row["borrow_count"])
    
  7. 文件不存在提示:

    try:
        with open("missing.txt", "r", encoding="utf-8") as file:
            print(file.read())
    except FileNotFoundError:
        print("错误:missing.txt 不存在")
    
  8. 每行写一项:

    results = ["优秀", "良好", "及格"]
    
    with open("results.txt", "w", encoding="utf-8") as file:
        for item in results:
            print(item, file=file)
    

7.14 常见错误清单#

  • 相对路径依赖当前工作目录,却从错误目录运行程序。
  • 使用 "w" 打开输入文件,导致原内容丢失。
  • 以为 write() 会自动换行。
  • 以为 writelines() 会自动补换行。
  • 忘记处理行末 \n,导致输出中出现空行。
  • 按固定索引访问字段,却没有先检查字段数。
  • CSV 读出的数字仍是字符串,直接参与错误类型的计算。
  • read() 读两次,第二次得到空字符串却以为文件损坏。
  • 捕获所有异常并静默忽略。
  • 结果目录不存在时直接写文件失败。

7.15 过关检查#

  • 能解释相对路径和当前工作目录。
  • 能正确写出 with open(..., encoding="utf-8")。
  • 能说清 r、w、a 的行为。
  • 能分别使用 read()、逐行循环和 readlines()。
  • 能写出带换行的文本文件。
  • 能用 split() 解析固定字段,并检查字段数量。
  • 能用 csv.DictReader 读取带表头的 CSV。
  • 能捕获 FileNotFoundError、ValueError 和权限错误。
  • 能完成成绩报告并验证正常、空文件、缺字段和非法数字。
  • 明确知道输出文件不能覆盖输入原件。

本周进度

完成讲义、练习和验收后再标记。