06 · 文件与异常(File & Exception)

📅 预计 60 分钟 | ⭐ = 高频考点
✍️ 本讲配套练习:本地 python grader.py 06,或网页练习


6.1 文件是什么:把数据存进硬盘的笔记本

程序跑在内存里,而内存有个特点:断电即失。你算了一下午的成绩单,关掉程序就没了——这当然不行。文件(file)就是程序把数据写到硬盘上的方式,相当于一本可以长期保存的笔记本:程序把它「打开」,在上面写字或读字,用完了再「合上」。下次开机,内容还在。

Python 操作文件的流程永远是三步:

  1. 打开open() 打开文件,像翻开笔记本。
  2. 读写:用 write() 写、用 read() 读。
  3. 关闭close() 合上笔记本,把内容真正落到硬盘。

最安全的写法是用 with 语句(也叫上下文管理器)。它像一个「细心的书童」:用完文件自动帮你合上,即使中途出错也会合上,你不用手动 close。with 是更规范也更常见的写法。

# 写文件:打开 → 写入 → 自动关闭
with open("data.txt", "w", encoding="utf-8") as f:
    f.write("第一行\n")
    f.write("第二行\n")
# 到这里文件已自动关闭,再打开读回来
with open("data.txt", "r", encoding="utf-8") as f:
    print(f.read())
# 输出:第一行
#      第二行

⚠️ 常见错误

  • 裸用 f = open(...) 却不 close():数据可能还躺在内存里没落盘,程序一结束就丢了。
  • "w" 去读文件:"w"先清空再写,原内容直接没了。
  • 漏写 encoding="utf-8":写中文时可能报 UnicodeEncodeError 或存成乱码。

6.2 打开模式:你怎么使用这本笔记本

open(path, mode, encoding=...) 的第二个参数是模式,决定你是「看」还是「写」;写的话,是从第 0 页开始,还是在最后一页接着写。

模式 含义 文件不存在时
"r" 只读(默认) 报错 FileNotFoundError
"w" 写,清空覆盖 创建
"a" 追加,在末尾接着写 创建
"r+" / "w+" 读写 r+ 报错 / w+ 创建

生活比喻:"r" 是只看不写;"w" 是撕掉旧本子重写一页;"a" 是在最后一页的末尾接着写,旧内容全保留。

# a 模式追加:每次运行都在末尾加一行
with open("log.txt", "a", encoding="utf-8") as f:
    f.write("2026-08-02 登录成功\n")
# 第一次运行后文件内容:
# 2026-08-02 登录成功
# 第二次运行后:
# 2026-08-02 登录成功
# 2026-08-02 登录成功

⚠️ 常见错误

  • 想追加却用 "w":每次运行都把旧记录清空,日志只剩最后一条。
  • "r+" 读不存在的文件:直接抛 FileNotFoundError,程序崩溃。要「先判断再读」见 6.7。

6.3 读文件的四种姿势

拿到文件对象 f 后,读内容有四种方式,用途不同:

# 1) read():一口气全读成一个大字符串
with open("data.txt", "r", encoding="utf-8") as f:
    content = f.read()          # "第一行\n第二行\n"

# 2) readline():只读一行
with open("data.txt", "r", encoding="utf-8") as f:
    line1 = f.readline()        # "第一行\n"
    line2 = f.readline()        # "第二行\n"

# 3) readlines():读出所有行,每行是列表的一个元素
with open("data.txt", "r", encoding="utf-8") as f:
    lines = f.readlines()       # ["第一行\n", "第二行\n"]

# 4) for line in f:逐行迭代,最省内存 ⭐
with open("data.txt", "r", encoding="utf-8") as f:
    for line in f:
        print(line.strip())     # strip() 去掉末尾换行

为什么「for line in f」最省内存?因为 read() 会把整个文件装进内存——文件有 1 个 G,内存就占 1 个 G;而 for 循环是读一行、处理一行,内存里始终只有一行。处理大文件(比如大数据课的日志)一定要用 for 迭代。

readlines() 返回的每行都带 \n,打印前记得 .strip(),否则会多出空行。

⚠️ 常见错误

  • 忘了 .strip():print 出来好像多了一行空行。
  • 大文件用 read():内存直接爆掉(大数据课程第一课就强调这个)。
  • 一个 read() 之后又调 read():第二次得到空字符串——因为指针已经到末尾了(见 6.5)。

6.4 写文件的两种姿势

写也有两个方法:write() 一次写一个字符串,writelines() 一次写一个字符串列表。

# write():写单个字符串,换行要自己加 \n
with open("a.txt", "w", encoding="utf-8") as f:
    f.write("第一行\n")
    f.write("第二行\n")

# writelines():一次写一个列表,列表里每个元素要自带 \n
lines = ["甲\n", "乙\n", "丙\n"]
with open("b.txt", "w", encoding="utf-8") as f:
    f.writelines(lines)

# 读回来验证
with open("b.txt", "r", encoding="utf-8") as f:
    print(f.readlines())        # ['甲\n', '乙\n', '丙\n']

⚠️ 常见错误

  • writelines 忘了给每个元素加 \n:所有内容挤成一行。
  • 以为 write 会自动换行:不会,你必须自己写 \n

6.5 文件指针 seek:读到哪里了

文件对象内部有个「指针」,记录你读到哪。read() 读完,指针停在末尾,再读就是空。seek(位置) 可以把指针挪回任意位置。

# 先造一个文件
with open("seek.txt", "w", encoding="utf-8") as f:
    f.write("abcdef")

# 把指针挪到下标 2,再读
with open("seek.txt", "r", encoding="utf-8") as f:
    f.seek(2)                   # 指针跳到第三个字符
    print(f.read())             # cdef

seek 的起点从 0 开始,f.seek(0) 就是回到文件开头。

⚠️ 常见错误

  • 忘了指针已经到末尾:连续两次 read(),第二次是空字符串。
  • 想重新读却不再 open:用 f.seek(0) 回到开头,或干脆重新打开。

6.6 文件编码:为什么中文会乱码

电脑只存 0 和 1,「中」这个字怎么对应到 0/1,要靠编码表(encoding)。Python 3 默认用 UTF-8,这是目前最通用的编码。如果文件是别的编码,读出来就是乱码,甚至报 UnicodeDecodeError

# 读中文文件:务必显式指定 encoding="utf-8"
with open("names.txt", "r", encoding="utf-8") as f:
    print(f.readline())         # 小明

写 CSV 给 Excel 用时,常用 utf-8-sig(带 BOM),这样 Excel 打开不乱码。

⚠️ 常见错误

  • 读写都忘写 encoding="utf-8":Windows 上可能默认用 GBK,中文全变乱码。
  • utf-8utf-8-sig 写的文件:第一行开头多个看不见的 

6.7 文件存在判断:动手前先问一问

读一个不存在的文件会直接抛 FileNotFoundError 让程序崩溃。写程序前先判断文件在不在,是常见的防御式写法。

import os

if os.path.exists("data.txt"):       # 文件或目录是否存在
    print("文件在")
else:
    print("文件不存在")

os.remove("data.txt")                # 删除文件
os.path.isdir("06-文件与异常")       # 判断是不是目录

也可以不判断,让它抛异常再用 try 接住(见 6.9)——两种思路都行。

⚠️ 常见错误

  • 直接 open 不判断:程序一遇缺失文件就崩。
  • 忘了 import os:直接 NameError: name 'os' is not defined

6.8 CSV 与 JSON:两种实用的数据格式

CSV(表格数据)

CSV 是「逗号分隔值」,Excel 和数据分析工具都能直接打开。Python 课的大作业常要求用它存成绩表。

import csv

# 写 CSV:newline="" 避免 Windows 下出现多余空行
with open("scores.csv", "w", newline="", encoding="utf-8-sig") as f:
    w = csv.writer(f)
    w.writerow(["姓名", "成绩"])       # 表头
    w.writerow(["小明", 90])
    w.writerow(["小红", 85])

# 读 CSV:每行是一个列表
with open("scores.csv", "r", encoding="utf-8-sig") as f:
    rows = list(csv.reader(f))         # [['姓名','成绩'], ['小明','90'], ['小红','85']]
    print(rows[1][0])                  # 小明

# 推荐 DictReader:把第一行当表头,按名字取值 ⭐
with open("scores.csv", "r", encoding="utf-8-sig") as f:
    for row in csv.DictReader(f):
        print(row["姓名"], row["成绩"])  # 小明 90 / 小红 85

JSON(结构化数据)

JSON 是「JavaScript 对象表示」,长得和 Python 字典几乎一样,是网页前后端交换数据的标准格式。

import json

data = {"name": "小明", "scores": [90, 85]}

# 写入:ensure_ascii=False 让中文以原文存储,而不是 \uXXXX
with open("data.json", "w", encoding="utf-8") as f:
    json.dump(data, f, ensure_ascii=False)

# 读取
with open("data.json", "r", encoding="utf-8") as f:
    obj = json.load(f)
print(obj["name"])                    # 小明

# 字符串 <-> 对象:网络传输用
s = json.dumps(data, ensure_ascii=False)   # 字典 -> 字符串
back = json.loads(s)                       # 字符串 -> 字典

记住 JSON 四件套:dump 字典→文件,load 文件→字典,dumps 字典→字符串,loads 字符串→字典。多一个 s 就是 string(字符串)。

⚠️ 常见错误

  • dump 忘写 ensure_ascii=False:文件里全是 小明,人看不出来是中文。
  • 读 JSON 忘写 encoding="utf-8":中文乱码。
  • json.load 遇到空文件:抛 JSONDecodeError

6.9 异常:程序出状况时的应急预案

异常(exception)是程序运行到「意料之外」时发出的信号。比如用户输入了字母你却要转整数、除以 0、读不存在的文件——Python 都会抛异常。如果不处理,程序就崩。try/except 就是应急预案:先试着跑,出问题就按预案处理,程序不崩。

try:
    num = int(input("输入数字:"))
    result = 100 / num
    print(result)
except ValueError:
    print("那不是数字!")
except ZeroDivisionError:
    print("不能除以 0")
except Exception as e:      # 兜底:抓其他所有异常,尽量别滥用
    print("出错了:", e)
else:
    print("没出错才执行这块")
finally:
    print("无论怎样都会执行")

执行顺序:

  1. try 里的代码先跑。
  2. 中途抛异常 → 跳到第一个匹配的 except,执行完继续往下走(try 里剩下的代码不再执行)。
  3. 没抛异常 → 执行 else(可选)。
  4. 不管有没有异常 → 执行 finally(可选),常用来关闭资源。

⚠️ 常见错误

  • except::把所有错误都吞了,出问题你完全不知道,程序带病运行。
  • except Exception 当万能兜底还不看类型:问题被掩盖,调试无从下手。
  • 在 except 里不处理、不记录、不打印:异常被静默吞掉,最隐蔽的 bug 之一。

6.10 一个 except 抓多种异常

不同错误对应不同异常类型。考试常考「这个操作抛什么异常」,先背熟:

int("abc")            # ValueError    值不合法
1 / 0                 # ZeroDivisionError  除零
lst = [1, 2, 3]
lst[5]                # IndexError    下标越界
d = {"a": 1}
d["b"]                # KeyError      键不存在
"a" + 1               # TypeError     类型不匹配
open("no.txt")        # FileNotFoundError  文件不存在

一个 except 可以同时抓多种异常:

try:
    x = int("abc") + "def"
except (ValueError, TypeError):
    print("类型或值有问题")

异常还有继承体系:ZeroDivisionErrorArithmeticError 的子类,而 ArithmeticError 又是 Exception 的子类。抓父类能抓到所有子类——所以 except Exception 什么都能抓,这也是它危险的原因。

⚠️ 常见错误

  • 异常类型拼错:写了 except ValueError:,结果真异常是 TypeError,根本没接住,程序照样崩。
  • 抓得太宽(except Exception)不看具体类型:把所有问题混为一谈,难定位。

6.11 主动抛异常:raise 与自定义异常

有时不是 Python 报错,而是业务规则不允许。比如成绩不能是负数、年龄不能是 500。这时可以用 raise 主动抛异常,让调用方统一处理。

def set_age(age):
    if age < 0 or age > 150:
        raise ValueError("年龄不合法")
    return age

set_age(200)   # 抛 ValueError: 年龄不合法

还可以定义自己的异常类型,让错误信息更有语义:

class ScoreError(Exception):
    """成绩不合法时抛出"""
    pass

def check_score(score):
    if score < 0 or score > 100:
        raise ScoreError("成绩超出 0-100")
    return score

try:
    check_score(-5)
except ScoreError as e:
    print(e)                 # 成绩超出 0-100

raise ... from ...:当你在异常处理里抛新异常时,用 from 把原始异常「挂」在后面,不丢根因——排错时能一路追到最初的错误。

def load_cfg(path):
    try:
        with open(path, encoding="utf-8") as f:
            return f.readline()
    except FileNotFoundError as e:
        raise ValueError("配置文件缺失") from e

⚠️ 常见错误

  • 在 except 里抛新异常但不 from e:原始错误信息丢失,排错困难。
  • 自定义异常类忘了继承 Exceptionexcept ScoreError 根本接不住。

6.12 综合示例:读取成绩文件并容错

把文件读取和异常处理结合,是这门课的常见题型。下面这个函数读取成绩文件,每行一个成绩,遇到空行跳过,遇到非法内容记一笔,最后返回统计。它也是 ex15 的参考答案思路。

def analyze_scores(path):
    total = 0
    count = 0
    bad = 0
    with open(path, encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line:            # 空行跳过
                continue
            try:
                total += int(line)
                count += 1
            except ValueError:      # 非法成绩,记一笔,程序不崩
                bad += 1
    return {"sum": total, "count": count, "bad": bad}

# 假设 scores.txt 内容是:90\nabc\n\n85\n
print(analyze_scores("scores.txt"))
# 输出:{'sum': 175, 'count': 2, 'bad': 1}

⚠️ 常见错误

  • int(line) 不包 try:一行数据坏了,整个文件读不了,程序直接崩。
  • FileNotFoundError 也吞进循环里:逻辑混乱,定位困难。

💡 记忆口诀

  • 文件三连:打开 → 读写 → 关闭;with 帮你关,最省心。
  • 模式别搞混r 读、w 清空写、a 追加。
  • 大文件循环读for line in f 最省内存。
  • 编码不写就乱码encoding="utf-8" 忘一次,乱码跟你走。
  • 异常五件套:try 试、except 接、else 无错、finally 必清、raise 主动抛。
  • JSON 四件套:dump 存文件、load 取文件、dumps 转字符串、loads 转对象。

📌 双语术语表(本讲)

中文 English
文件读写 file I/O
只读 / 写入 / 追加 read / write / append
上下文管理器 context manager(with)
文件指针 file pointer / seek
编码 encoding
异常 exception
抛出 raise
捕获 catch
清理 finally
解析 / 序列化 parse / serialize
自定义异常 custom exception
存在性检查 existence check

⭐ 本讲考点清单

  1. with open(...) as f + encoding="utf-8"
  2. "r" 只读、"w" 清空、"a" 追加
  3. 读取三件套:read() / readline() / readlines();大文件用 for line in f
  4. write()writelines()(记得加 \n
  5. seek(0) 回到开头 / 文件指针位置
  6. 文件编码 UTF-8 与乱码原因
  7. os.path.exists / os.remove 文件存在判断
  8. CSV 读写(csv.reader / DictReader / utf-8-sig
  9. JSON dump/load + ensure_ascii=False
  10. try/except/else/finally 执行顺序
  11. 常见异常:ValueError / ZeroDivisionError / IndexError / KeyError / TypeError / FileNotFoundError
  12. raise、自定义异常、raise ... from ...
  13. 综合:文件读取 + 异常容错