你第一次写文件程序时,很可能会有一种错觉:open()、read()、write() 就三个动作,照着例子抄下来,文件操作也就学完了。真正让人摔跟头的,往往是例子之外的现实情况:程序从另一个目录启动,相对路径突然失效;同学发来的文件不是 UTF-8,中文读到一半就报错;配置文件只写了一半,下一次启动时连 JSON 都解析不了;为了“让程序别崩”,写了一个 except Exception: pass,结果错误消失了,数据也跟着悄悄丢了。
这一章就从这些不太顺利的时刻开始。我们会做一个小型的“学习计划配置”读写程序,逐步把路径、编码、上下文管理器、JSON 和异常处理串起来。目标不只是把文件读出来,而是知道每一步可能失败在哪里,失败以后该给谁处理,以及怎样尽量不破坏用户原本的数据。

把路径交给 Path 对象处理,能避免手拼斜杠带来的跨平台问题。
文件操作和异常处理其实是同一件事的两面:前者让程序接触磁盘、编码和操作系统,后者负责把这些不确定结果变成清楚、可处理的分支。只会写“成功路径”的文件代码,通常还不能算写完。
假设项目里明明有 settings.json,下面这段代码却抛出了 FileNotFoundError:
with open("settings.json", encoding="utf-8") as file:
print(file.read())先别急着怀疑 Python。相对路径不是“相对于这段 .py 文件”,而是相对于进程的当前工作目录。你在项目根目录执行脚本、在编辑器里点运行、让测试工具启动脚本,当前工作目录可能各不相同。同一个字符串 settings.json,自然可能指向三个不同的位置。
可以先把现场打印出来:
from pathlib import Path
print("当前工作目录:", Path.cwd())
print("实际尝试的位置:", (Path.cwd() / "settings.json").resolve())Path.cwd() 表示当前工作目录。resolve() 会把路径整理成绝对形式,便于排查“我以为在这里,程序其实去了那里”的问题。调试路径问题时,打印最终路径通常比反复修改文件名有效得多。
过去常见的写法是手动拼字符串:
path = "data/" + username + "/settings.json"这种写法容易漏掉分隔符,也把路径当成了普通文本。pathlib.Path 更像是在直接描述“目录下面的文件”:
from pathlib import Path
data_dir = Path("data") / "小林"
config_path = data_dir / "settings.json"
data_dir.mkdir(parents=True, exist_ok=True)
print(config_path)
print(config_path.name) # settings.json
print(config_path.suffix) # .json
print(config_path.parent) # data/小林/ 在这里不是除法,而是 Path 定义的路径连接操作。parents=True 表示缺少的上层目录也一起创建,exist_ok=True 表示目录已经存在时不要把它当成错误。不过,如果那个位置已经有一个同名普通文件,mkdir() 仍然会失败;“允许目录已存在”不等于“任何冲突都忽略”。
如果资源明确跟脚本放在一起,可以从 __file__ 出发:
from pathlib import Path
script_dir = Path(__file__).resolve().parent
config_path = script_dir / "data" / "settings.json"如果是命令行工具,用户通常希望相对路径跟随自己所在的目录,这时从 Path.cwd() 出发反而合理。路径基准没有一个适用于所有程序的答案,关键是把规则定清楚:程序自带资源跟着脚本走,用户输入和当前任务通常跟着工作目录走,用户长期配置则常放到专门的配置目录里。
路径对象本身也不保证对应位置真的存在。Path("data/settings.json") 只是把一条路径表达出来,创建它不会访问磁盘。等你调用 open()、stat()、read_text() 这类方法时,操作系统才会参与进来。这一点很重要:我们可以放心地先组合目标路径,再在真正操作的位置统一处理异常,不必每拼一段路径就做一次存在性检查。
is_file() 和 is_dir() 适合需要区分文件类型的界面提示,但它们同样只是当下的一次观察。如果用户明确要求导入一个文件,最终仍应以打开操作的结果为准。一个路径可能在检查时是文件,下一瞬间被另一个进程换成目录;也可能存在却没有读取权限。路径排错的顺序可以固定下来:先确认基准目录,再打印整理后的完整路径,然后看对象类型和权限,最后保留原始异常。这样比把所有问题都归结为“文件不存在”准确得多。
还有一个常见误会:绝对路径不代表“更正确”,只代表它不依赖当前工作目录。把自己电脑上的 /Users/某人/Desktop/data.json 写死到代码里,换台电脑几乎一定失效。更好的做法是从稳定基准出发组合路径,或者把目标路径作为命令行参数、函数参数交给程序。路径应该是输入或配置的一部分,不应藏在函数深处成为只有作者知道的秘密。
Path.exists() 只能说明检查的那一刻路径是否存在。检查结束到真正打开之间,文件仍可能被移动、删除或替换。需要读取文件时,通常直接尝试打开并捕获具体异常更可靠。
open() 会返回一个文件对象。这个对象保存着打开模式、当前位置、编码器以及底层系统资源。可以把它理解成程序和磁盘文件之间的一条通道:read() 从通道取数据,write() 把数据送进去,close() 关闭通道。
from pathlib import Path
notes_path = Path("data") / "notes.txt"
notes_path.parent.mkdir(parents=True, exist_ok=True)
file = notes_path.open("w", encoding="utf-8")
try:
file.write("文件对象是一条通往磁盘数据的通道。\n")
finally:
file.close()这段代码能正确清理资源,但每次手写 try/finally 很啰嗦,也容易忘。我们通常把它写成 with:
with notes_path.open("w", encoding="utf-8") as file:
file.write("即使写入过程中出错,文件也会被关闭。\n")with 使用的是上下文管理协议。进入代码块时,对象的 __enter__() 被调用;离开代码块时,不管是正常执行完、提前 return,还是中途抛出异常,__exit__() 都会被调用。文件对象在退出阶段完成关闭,所以清理动作不会依赖你是否记得写 close()。
with notes_path.open(encoding="utf-8") as file:
first_line = file.readline()
print(first_line)
print(file.closed) # True文件关闭以后,已经读入内存的字符串 first_line 还能用,文件对象却不能继续读取。下面的写法会触发 ValueError:
with notes_path.open(encoding="utf-8") as file:
pass
file.read() # 这里会触发 ValueError,因为文件已经关闭所以要先分清两件事:你是想在 with 外处理“读出来的数据”,还是还想继续向“文件对象”要数据。前者没问题,后者必须放在打开期间完成。
我们平时很少给文件手动调用 __enter__() 和 __exit__(),但知道协议怎样传递异常,会更容易理解 with 为什么不会把错误自动藏起来。退出方法会收到异常类型、异常对象和 traceback。它返回真值时,表示异常已经被上下文管理器处理,不再向外传播;返回假值或 None 时,原异常继续传播。
下面这个教学用上下文管理器会打印进入和离开过程,但不会吞掉异常:
class StudySession:
def __init__(self, topic: str):
self.topic = topic
def __enter__(self):
print(f"开始学习:{self.topic}")
return self
def __exit__(self, exc_type, exc_value, traceback):
if exc_type is None:
print("本次学习正常结束")
else
如果代码块里出现错误,__exit__() 仍然会运行;因为它返回 False,错误随后照常交给外层。文件对象也会在退出时关闭资源,同时不会无缘无故压住读写错误。理解这件事以后,你会发现 with 解决的是“无论如何都清理”,不等于“无论什么错误都当作成功”。
自己实现上下文管理器时,不要为了让终端看起来干净就随手返回 True。只有当对象确实完成了恢复,而且调用者可以把本次操作视为成功或明确的替代结果时,才应该阻止异常继续传播。多数资源管理器的退出阶段只负责清理,业务错误仍应留给外层决定。
复制文本时,可以在一个 with 里同时打开源文件和目标文件:
from pathlib import Path
source = Path("data/source.txt")
target = Path("data/copy.txt")
with (
source.open("r", encoding="utf-8") as reader,
target.open("w", encoding="utf-8") as writer,
):
for line in reader:
writer.write(line)离开代码块时,两个文件都会被关闭。这个模式也适用于锁、数据库连接等实现了上下文管理协议的对象。文件只是最常见的一个例子。
文件在磁盘上保存的是字节,Python 里的文本是 str。用文本模式打开文件时,Python 会在中间做转换:读取时按指定编码把字节解码成字符串,写入时把字符串编码成字节。
text = "今天复习异常处理"
raw = text.encode("utf-8")
print(raw)
print(raw.decode("utf-8"))只要写入和读取使用相同的编码,往往感觉不到转换过程。一旦文件实际使用 GBK,却按 UTF-8 去读,某组字节无法解释成合法字符,就会抛出 UnicodeDecodeError。
from pathlib import Path
path = Path("data/message.txt")
path.write_text("中文内容", encoding="gbk")
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError as exc:
print(f"解码失败:第 {exc.start} 个字节附近不是有效的 UTF-8")文本模式省略 encoding 时,会使用运行环境的默认编码。你的电脑上能读,不代表同学的电脑、服务器或持续集成环境上也能读。对由自己控制的文本文件,直接约定 UTF-8 并在每次读写时写明 encoding="utf-8",能少掉很多跨平台问题。
遇到未知来源的文件,别用 errors="ignore" 当万能修复。它会跳过无法解码的字节,程序似乎继续运行了,但姓名、金额或配置键可能已经缺字。更稳妥的做法是让错误暴露出来,再根据文件来源确认编码;如果产品确实允许容错,也要记录替换或丢弃发生过。
有些 UTF-8 文本开头带有字节顺序标记,常被简称为 BOM。读取普通正文时,它可能表现成开头多出的不可见字符,导致第一个字段名明明看起来是 name,比较时却对不上。确认输入确实是“带 BOM 的 UTF-8”后,可以使用 encoding="utf-8-sig",它会在读取时处理这个标记。不要因此把所有文件都默认改成 utf-8-sig;先确认生产者和格式约定,仍然是最稳妥的办法。
编码错误也分读取与写入两边。读取时常见 UnicodeDecodeError,表示原始字节无法解释;写入时,如果目标编码不能表示某个字符,会出现 UnicodeEncodeError。例如 GBK 未必能表示所有 Unicode 字符。把两者都叫“乱码”会漏掉关键信息:前者需要确认文件原编码,后者需要决定是否改用 UTF-8,或是否真的允许替换无法表示的字符。
“能打开”不等于“解码正确”。某些错误编码组合不会立刻抛异常,只会产生乱码。可靠的编码策略来自明确约定、文件格式说明或可信元数据,不来自把编码列表挨个试到某个不报错为止。

文本与字节之间需要明确编码约定,写入和读取编码不一致就容易产生乱码。
文本文件读写 str,二进制文件读写 bytes:
from pathlib import Path
text_path = Path("data/hello.txt")
binary_path = Path("data/sample.bin")
with text_path.open("w", encoding="utf-8") as file:
file.write("你好")
with binary_path.open("wb") as file:
file.write(b"\x50\x59\x54\x48\x4f\x4e图片、压缩包和音频通常按二进制处理,不要给二进制模式传 encoding。同样,文本模式的 write() 需要字符串,直接写 bytes 会触发 TypeError。
读一个两千字的配置说明,一次 read() 很方便;读一个几 GB 的日志,一次塞进内存就不合适了。几种读取方式没有“谁更高级”,区别主要在于一次拿多少数据、是否要保留换行符,以及后续怎样处理。
from pathlib import Path
path = Path("data/todo.txt")
path.write_text("复习路径\n练习异常\n整理笔记\n", encoding="utf-8")
with path.open("r", encoding="utf-8") as file:
content = file.read()
print(content)read() 不传参数会读到文件末尾。文本模式下,read(10) 最多读取 10 个字符;二进制模式下对应的是字节。它不是“第 10 行”,也不是固定的磁盘块大小。
文件对象还记录当前位置。连续调用两次 read() 时,第二次会从第一次停止的位置继续:
with path.open(encoding="utf-8") as file:
part1 = file.read(4)
part2 = file.read(4)
rest = file.read()
print(part1, part2, rest)读到末尾后再次读取会得到空字符串。确实需要回到开头时可以 file.seek(0),但如果代码不断来回跳位置,通常值得重新考虑数据处理方式是否过于绕。
with path.open(encoding="utf-8") as file:
first = file.readline()
second = file.readline()
print(repr(first))
print(repr(second))readline() 会保留行末换行符,读到文件末尾则返回 ''。用 repr() 打印,可以看见字符串末尾的 \n,这对排查空行和换行问题很有帮助。
这里还藏着一个很实用的区别:文件中的空行通常读到的是 "\n",真正到达文件末尾才是空字符串 ""。如果用 if not line: 判断结束,它能正确区分两者;如果一上来先 strip(),空行和文件末尾都变成 "",你就失去了原始信息。需要解析段落、空行分组或配置格式时,先判断是否读到末尾,再按规则处理这一行。
手写 while 读取时可以这样表达:
with path.open(encoding="utf-8") as file:
while True:
line = file.readline()
if line == "":
break
print("空行" if line in {"\n", "\r\n"} else line.rstrip("\r\n"))多数时候直接 for line in file 更简洁。readline() 适合你确实需要逐次控制“再拿一行”的解析器,例如先读表头,再按表头决定后续读取方式。
with path.open(encoding="utf-8") as file:
for line_number, line in enumerate(file, start=1):
item = line.rstrip("\n")
print(f"{line_number}: {item}")文件对象本身可以迭代,循环会一行一行地取数据,不必先把全部内容组成列表。readlines() 则会把各行放进列表,适合文件不大、关闭文件后还要多次访问所有行的情况。
这里故意用了 rstrip("\n"),没有直接写 strip()。strip() 会同时删除两端所有空白,可能把本来有意义的缩进和空格也抹掉。处理日志或代码文件时,这种“顺手清理”经常改坏原始语义。如果还要兼容保留下来的 \r\n,可以使用 rstrip("\r\n");但它会删除末尾连续的回车与换行字符,是否合适仍取决于数据规则。
打开模式会在 write() 之前就影响文件。尤其是 'w':文件存在时,一旦成功打开便会被截断,哪怕后面还没来得及写入有效内容。
下面的实验台把文件想象成一个可以打开、清空和移动指针的文件柜。选择文件是否已存在,再切换不同模式,观察“打开那一刻”和“执行读写以后”分别发生了什么。
from pathlib import Path
log_path = Path("data/study.log")
with log_path.open("a", encoding="utf-8") as file:
file.write("完成文件读取练习\n")
file.write("完成异常捕获练习\n")write() 返回写入的字符数,但不会替你加 \n。writelines() 也不会自动加换行:
items = ["路径", "编码", "异常"]
with Path("data/topics.txt").open("w", encoding="utf-8") as file:
file.writelines(f"{item}\n" for item in items)如果直接 file.writelines(items),文件内容会变成连在一起的“路径编码异常”。方法名里的 lines 容易让初学者误以为它会负责分行,这个坑很常见。
保存新作业、生成唯一编号报告时,覆盖同名文件可能比保存失败更糟。'x' 会在目标已存在时抛出 FileExistsError,让程序有机会请用户换名:
from pathlib import Path
report = Path("data/report.txt")
try:
with report.open("x", encoding="utf-8") as file:
file.write("本周学习报告\n")
except FileExistsError:
print("报告已存在,没有覆盖原文件")这比先 exists() 再用 'w' 更接近真实要求,因为“确认不存在”和“排他创建”由一次文件系统操作完成。至于 '+',它允许同一个文件对象读写,但要特别留意当前位置以及读写切换时的刷新、定位规则。初学阶段若没有必须原地修改的理由,分别读取、生成新内容、再安全替换,通常更容易写对。
'a' 很适合简单日志,因为它不清空旧内容。但多个进程同时写长文本时,一条逻辑记录仍可能被拆成多次底层写入,顺序和完整性不能只靠 'a' 保证。需要严格一致的业务日志时,还要考虑单独的日志系统、进程间协调或数据库。入门阶段先记住:追加保护的是“从末尾写”,不是自动解决所有并发问题。
不同系统历史上使用过 \n、\r\n 等行结束方式。文本模式默认会做通用换行处理:读取时通常把常见行结尾转换成 \n;写入时可能把 \n 转换为系统行分隔符。
如果你希望生成的配置文件在各平台都固定使用 \n,可以明确传入:
with Path("data/result.txt").open(
"w",
encoding="utf-8",
newline="\n",
) as file:
file.write("第一行\n第二行\n")不要把 newline 和 encoding 混为一谈:前者决定行结束如何转换,后者决定字符怎样变成字节。

文件对象有当前读写位置;不同读取方式、打开模式和缓冲策略适用于不同场景。
文件写入通常经过缓冲区。程序调用 write() 后,数据可能先留在内存缓冲里,积累到一定程度或文件关闭时再交给操作系统。这样能减少大量细小的系统调用,通常更高效。
from pathlib import Path
path = Path("data/progress.txt")
with path.open("w", encoding="utf-8") as file:
file.write("进度:50%\n")
file.flush()
print("数据已从 Python 的文件缓冲提交给操作系统")flush() 会刷新 Python 层的缓冲,但“交给操作系统”仍不一定等于已经永久落到物理介质。如果场景对断电后的持久性要求很高,可以在 flush() 后调用 os.fsync(file.fileno())。普通学习脚本通常不需要每写一行就这样做,因为频繁同步会影响性能。
import os
from pathlib import Path
path = Path("data/important.txt")
with path.open("w", encoding="utf-8") as file:
file.write("这份数据需要尽量及时同步。\n")
file.flush()
os.fsync(file.fileno())离开 with 会刷新并关闭文件,因此正常的短脚本不用为了“确保写入”在每段代码后手动 flush()。缓冲知识主要用来解释实时日志为什么延迟出现,以及后面安全保存为何要在替换前先刷新临时文件。
纯文本适合笔记,配置和进度数据通常需要保留字段与层次。JSON 能表达对象、数组、字符串、数字、布尔值和空值;它们在 Python 中通常对应字典、列表、字符串、数字、True、False 和 None。
import json
from pathlib import Path
settings = {
"student": "小林",
"daily_minutes": 45,
"topics": ["文件", "异常"],
"reminder": True,
"last_opened": None,
}
path = Path("data/settings.json")
path.parent.mkdir(parents=
dump() 直接把对象序列化到文件对象;dumps() 返回 JSON 字符串。反过来,load() 从文件对象读取,loads() 从字符串读取。末尾的 s 可以记成 string,但不要只背口诀,要看你手里拿到的是文件对象还是字符串。
with path.open("r", encoding="utf-8") as file:
loaded = json.load(file)
text = json.dumps(loaded, ensure_ascii=False)
restored = json.loads(text)
print(restored["topics"])ensure_ascii=False 让中文以可读字符写入,而不是显示成一串 Unicode 转义;indent=2 让嵌套结构更便于人工检查。它们影响表示形式,不改变加载后的数据含义。
JSON 对对象键的规则比 Python 字典窄,键最终会表示成字符串。一个含整数键的字典写入再读回,键可能从整数变成字符串:
import json
original = {1: "入门", 2: "练习"}
restored = json.loads(json.dumps(original, ensure_ascii=False))
print(original) # {1: '入门', 2: '练习'}
print(restored) # {'1': '入门', '2': '练习'}
print(original == restored) # False元组写入 JSON 数组后,读回来通常也会成为列表。JSON 适合交换一组约定清楚的基础数据,不是把任意 Python 对象原样冻住再复活。设计保存格式时,应先写清每个字段的 JSON 类型、是否必填、允许范围和版本迁移规则,再决定 Python 里用什么对象承载。
序列化阶段抛出 TypeError 其实是好事,它在正式数据写出前告诉你“这个对象没有约定表示”。相反,打开 skipkeys=True 会静默跳过不支持的键,可能让配置少字段却没有任何警告。除非你非常确定丢弃这些条目就是产品规则,否则保留默认的严格行为更安全。

JSON 可以直接承载常见基础类型,日期和自定义对象则需要额外转换。
日期时间、集合、自定义类实例默认不能直接写成 JSON:
import json
from datetime import datetime
data = {"saved_at": datetime.now()}
try:
json.dumps(data)
except TypeError as exc:
print(exc)这不是 JSON 模块“不够聪明”,而是它不知道你希望如何跨语言表示这些对象。日期可以保存成 ISO 格式字符串,集合可以在明确不需要无重复语义时转成列表,自定义对象则应挑选真正需要持久化的字段。转换规则应该由业务决定,不要随手对所有对象调用 str(),否则读回来时很难恢复类型。
from datetime import datetime, timezone
data = {
"saved_at": datetime.now(timezone.utc).isoformat(),
"tags": sorted({"Python", "文件"}),
}少一个引号、多一个尾逗号、只写了一半,都会让 json.load() 抛出 json.JSONDecodeError。异常对象包含行号和列号,可以把错误位置告诉用户:
import json
from pathlib import Path
path = Path("data/settings.json")
try:
with path.open(encoding="utf-8") as file:
settings = json.load(file)
except json.JSONDecodeError as exc:
print(f"JSON 格式错误:第 {exc.lineno} 行,第 {exc.colno还要注意,一个普通 JSON 文档只能有一个顶层值。连续对同一个文件调用多次 json.dump(),不会自动形成一个合法的“记录序列”。如果要保存多条记录,可以把它们放进一个列表后整体写入,或另行采用每行一个 JSON 对象的明确格式。
程序执行到无法按原计划继续的位置时,会抛出异常对象。异常沿调用栈向外寻找匹配的处理器;找不到就终止当前执行,并打印 traceback。回溯信息从外层调用一路指向真正出错的位置,最后一行给出异常类型和消息。
初学时容易只盯着“报错了”三个字,其实类型很有用:
FileNotFoundError:请求的文件或目录不存在。PermissionError:操作系统拒绝了这次访问。IsADirectoryError:把目录当成普通文件操作。UnicodeDecodeError:字节无法按指定编码解码。json.JSONDecodeError:文本不是合法 JSON 文档。TypeError:对象类型不符合操作要求,例如向文本文件写入字节。ValueError:类型大体正确,但值不满足要求,例如把 "四十五" 交给 int()。这些类型有继承关系。大多数应用层会处理的异常继承自 Exception;文件系统相关错误通常在 OSError 分支下面,FileNotFoundError、PermissionError 都是更具体的子类。捕获父类会同时匹配它的子类,所以顺序要从具体到宽泛:
from pathlib import Path
try:
text = Path("data/settings.json").read_text(encoding="utf-8")
except FileNotFoundError:
print("配置文件不存在")
except PermissionError:
print("没有权限读取配置文件")
except OSError as exc:
print(f"其他文件系统错误:{exc}"如果把 except OSError 放在最前面,后面的两个分支永远没有机会执行。程序仍能运行,但你失去了针对具体问题给出具体处理的能力。

异常按照从具体到宽泛的顺序匹配,正常与异常路线最终都会进入 finally。
通常不要捕获 BaseException。KeyboardInterrupt 和 SystemExit 这类用于中断或退出程序的异常并不属于普通业务失败,宽泛地拦住它们会让用户按下中断键后程序仍不退出。
看到一大段 traceback 时,新手很容易从第一行开始逐字翻译,读了半天仍不知道先改哪里。更有效的顺序是先看最后一行:异常类型说明失败类别,冒号后的消息给出具体原因;然后从下往上找自己项目里的最后一个调用位置,它通常最接近错误发生点。上面的调用记录则回答“程序为什么会走到这里”。
假设界面调用 start_plan(),它再调用 load_settings(),最后在 json.load() 里失败。最底部可能显示 JSONDecodeError,上方依次列出标准库解析位置、load_settings() 的文件行号以及 start_plan() 的调用行号。修复格式时先看 JSON 错误给出的行列;思考谁该提示用户时,再沿调用关系向上看。
不要靠搜索异常消息里的中文或英文词语来判断错误:
try:
load_settings(Path("data/settings.json"))
except OSError as exc:
if "not found" in str(exc).lower():
print("文件不存在")消息可能随操作系统、语言环境和 Python 版本变化。应该捕获 FileNotFoundError 这样的类型。需要更细的系统信息时,OSError 实例还可能提供 errno、filename 等属性,但业务代码首先应依赖已经划分好的具体异常类。
底层函数最了解技术原因,上层流程最了解业务选择。文件读取函数知道是编码错误还是 JSON 错误,却未必知道该弹窗、重试还是退出;命令行入口知道怎样给用户提示,却不应该重新实现解码细节。因此常见做法是底层捕获少量具体异常,补充路径或行列信息后转换成业务异常;上层捕获业务异常,选择展示和退出方式。
class ImportErrorForUser(Exception):
pass
def read_import_file(path: Path) -> dict:
try:
with path.open(encoding="utf-8") as file:
return json.load(file)
except FileNotFoundError as exc:
raise ImportErrorForUser(f"找不到导入文件:{path}") from
这并不要求每一层都捕获再抛一次。没有新信息、没有恢复动作、也不需要转换接口时,让异常自然穿过这一层最清楚。异常处理写得多不等于可靠,处理位置恰好掌握决策所需信息才可靠。
完整的 try 结构看起来关键字很多,其实每一段的职责很清楚:try 放可能失败的动作,except 处理你真正能应对的失败,else 放成功后才继续的动作,finally 放无论结果如何都要执行的清理。
import json
from pathlib import Path
path = Path("data/settings.json")
try:
with path.open(encoding="utf-8") as file:
settings = json.load(file)
except FileNotFoundError:
print("没有找到配置,准备使用默认设置")
settings = {"daily_minutes": 30
Python 先执行 try。如果 path.open() 就失败,后面的 json.load() 不会继续,程序会直接寻找匹配的 except。
如果异常类型匹配某个 except,只执行第一个匹配分支。处理完成后不会再去执行 else。
如果 try 从头到尾没有抛异常,所有 except 都跳过,执行 else。把成功后的业务放在这里,可以避免 try 范围过大而误抓其他错误。
下面这段代码的 try 太宽了:
try:
settings = load_settings()
minutes = settings["daily_minutes"]
start_timer(minutes)
except KeyError:
print("配置缺少必要字段")如果 start_timer() 内部也因为别的字典缺键而抛出 KeyError,这个处理器会把它误报成“配置缺字段”。更清楚的写法是缩小范围:
settings = load_settings()
try:
minutes = settings["daily_minutes"]
except KeyError as exc:
raise ValueError("配置缺少 daily_minutes 字段") from exc
else:
start_timer(minutes)else 的价值就在这里:它让“只有成功才做”与“可能出现预期异常的最小代码”分开。
文件通常用 with 清理,不必再手写 finally: file.close()。finally 更适合管理还没有上下文管理器包装的资源,或恢复临时状态:
locked = acquire_lock()
try:
update_shared_file()
finally:
if locked:
release_lock()尽量不要在 finally 里 return。它可能覆盖 try 里的返回值,甚至让正在传播的异常消失,读代码的人也很难判断函数最后会返回什么。
下面的追踪器会把正常执行、已处理异常、未匹配异常和 else 中再次出错四种路线逐步点亮。切换场景时,重点观察“剩余的 try 代码何时被跳过”和“finally 为什么总在离开前出现”。
捕获某个异常,意味着当前这一层知道怎样恢复、转换或报告。只打印一句“出错了”然后继续,往往没有完成处理。
“配置文件第一次还不存在”可以是正常业务状态,此时返回默认配置合理:
import json
from pathlib import Path
DEFAULT_SETTINGS = {"daily_minutes": 30, "reminder": True}
def load_settings(path: Path) -> dict:
try:
with path.open(encoding="utf-8") as file:
return json.load(file)
except FileNotFoundError:
但“已经存在的配置损坏”不能也当成首次运行。否则用户会看到设置突然恢复默认,却不知道自己的文件出了问题。恢复策略要区分原因,而不是所有失败都返回同一个空字典。
如果只是想记录现场,记录后要重新抛出:
import logging
logger = logging.getLogger(__name__)
try:
settings = load_settings(Path("data/settings.json"))
except OSError:
logger.exception("读取配置文件失败")
raise不带参数的 raise 会重新抛出当前正在处理的异常,并保留原有 traceback。这样,上层仍能决定重试、提示用户或终止任务。
底层函数关心 JSON 和编码,上层界面可能只想处理统一的“配置错误”。可以定义自己的异常,并使用 raise ... from ... 建立异常链:
import json
from pathlib import Path
class SettingsError(Exception):
"""表示学习计划配置无法使用。"""
def load_settings(path: Path) -> dict:
try:
with path.open(encoding="utf-8") as file:
return json.load(file)
except UnicodeDecodeError as exc:
raise SettingsError("配置文件必须使用 UTF-8 编码"
异常链同时保留两层信息:调用者看到的是适合当前业务的 SettingsError,排查时仍能追到最初的 UnicodeDecodeError 或 JSONDecodeError。如果故意使用 from None,底层上下文会在默认回溯展示中隐藏;只有确认底层细节确实会干扰用户,而且调试信息已有其他保留方式时,才考虑这样做。

只捕获预期的具体异常,并通过异常链保留底层原因,才能让失败可处理、可追踪。
Python 代码里常见 EAFP 风格:先执行合理操作,失败就捕获预期异常。与它相对的是 LBYL,也就是行动前先做一串检查。
读取文件就是一个典型例子:
from pathlib import Path
path = Path("data/settings.json")
if path.exists():
with path.open(encoding="utf-8") as file:
text = file.read()更直接的 EAFP 写法是:
try:
with path.open(encoding="utf-8") as file:
text = file.read()
except FileNotFoundError:
text = "{}"第二种写法让真正的文件操作决定结果,也避免把“存在性检查”误当成保证。文件系统可能被其他进程改变,权限也可能在检查之后变化。
不过,EAFP 不是“什么都别验证”。用户输入的每日学习时长必须是整数,而且业务规定在 1 到 600 分钟之间,这种规则就应该主动检查:
def parse_daily_minutes(raw: str) -> int:
try:
minutes = int(raw)
except ValueError as exc:
raise ValueError("每日时长必须是整数") from exc
if not 1 <= minutes <= 600:
raise ValueError("每日时长必须在 1 到 600 分钟之间")
return minutes类型转换失败适合由异常表达,数值范围则是清楚的业务条件,用 if 验证更自然。所谓边界,不是选一个风格贯彻到底,而是看谁掌握事实:操作系统决定文件能否打开,就直接尝试;业务代码掌握允许的取值范围,就在进入核心逻辑前明确验证。
异常适合表达“当前操作无法按约定完成”,不适合代替普通分支。列表里有没有待办事项、用户是否开启提醒,都是正常状态判断;文件突然读不了、JSON 结构损坏,才是异常路径。
遇到错误时,先保留异常类型和现场,再判断恢复策略。别一上来就把所有异常翻译成“文件有问题”,那会丢掉最有用的线索。
常见原因包括相对路径基准错误、文件名大小写不同、父目录缺失,或者你把“读取已有文件”与“首次创建文件”混在了一起。
from pathlib import Path
path = Path("data/settings.json")
try:
with path.open(encoding="utf-8") as file:
content = file.read()
except FileNotFoundError as exc:
print("当前工作目录:", Path.cwd())
print("尝试读取路径:", path.resolve())
print("操作系统报告:", exc)不要看到不存在就自动创建空文件。词典程序缺少可选缓存,可以重新生成;记账程序缺少账本,却悄悄创建空文件,会让用户误以为旧数据被清空。是否创建默认文件是业务决定。
先确认文件是谁生成的、约定编码是什么,再用对应编码读取。若是你自己的程序生成,统一 UTF-8;若是外部导入,应让用户选择或让上游提供编码信息。错误对象里的位置能帮助定位出问题的字节,但它不能自动告诉你“正确编码是哪一个”。
try:
content = path.read_text(encoding="utf-8")
except UnicodeDecodeError as exc:
print(f"UTF-8 解码失败,字节范围 {exc.start}:{exc.end}")
raise解析成功只说明 JSON 语法成立。下面这个文件是合法 JSON,却不满足程序要求:
{
"daily_minutes": "很多",
"reminder": "打开"
}所以读取配置分两层:先由 json.load() 检查语法,再由业务代码检查字段、类型和范围。
def validate_settings(data: object) -> dict:
if not isinstance(data, dict):
raise ValueError("配置顶层必须是对象")
minutes = data.get("daily_minutes")
if not isinstance(minutes, int) or isinstance(minutes, bool):
raise ValueError("daily_minutes 必须是整数")
if not
这里特意排除了 bool。在 Python 里 bool 是 int 的子类,单写 isinstance(True, int) 会得到 True,但“每天学习 True 分钟”显然不是我们想接受的数据。
批量导入十个配置文件时,第一个坏文件不一定应该让后面九个都停下;但“继续处理”也不等于静默忽略。比较实用的设计是让每个文件拥有独立的 try,成功结果和失败报告分别收集,最后把完整结果交给用户。
from pathlib import Path
def load_many(paths: list[Path]) -> tuple[list[dict], list[str]]:
loaded = []
errors = []
for path in paths:
try:
settings = load_settings(path)
except SettingsError as exc:
errors.append(f"{path.name}:{exc
如果把 try 套在整个 for 外面,任何一个文件失败都会直接跳出循环。放在循环里面,失败边界就是“当前文件”。这里仍然没有捕获所有 Exception:代码拼写错误、错误的函数调用等程序缺陷应该尽快暴露,而不是混进“某文件导入失败”的业务报告里。
批量任务还要明确结果契约。只返回成功列表会让调用者不知道是十个全部成功,还是九个失败后只剩一个;只打印错误又不方便界面或测试使用。返回成功项和结构化错误,或定义一个包含统计信息的结果对象,都比在函数内部随意打印更容易维护。
是否遇错立即停止,取决于任务之间有没有依赖。十份互不相关的作业可以分别处理;数据库迁移的后一步依赖前一步,就应该在第一个失败处停止并回滚。异常机制只告诉你某个动作失败,批量策略仍然要由业务规则决定。
最危险的异常处理往往很短:
try:
save_settings()
except Exception:
pass它会捕获几乎所有普通异常,然后既不恢复、也不记录、也不通知调用者。磁盘已满、权限不足、程序变量写错,外部表现都变成“函数执行完了”。用户以为保存成功,下次打开才发现数据没了。
如果某个失败确实可以忽略,也要把范围和类型写具体:
from pathlib import Path
cache_path = Path("data/preview.cache")
try:
cache_path.unlink()
except FileNotFoundError:
pass # 缓存本来就不存在,目标状态已经达成这里的 pass 有清楚语义:目标是让缓存不存在,而 FileNotFoundError 说明目标已经满足。权限不足不能忽略,其他路径错误也不能忽略,所以只捕获这一种。
面对宽泛的 Exception,常见的合理模式有两个:应用最外层记录未预期错误并给用户统一提示;或者当前层记录上下文后立即 raise。在可复用函数内部用它返回空值,通常会让调用者分不清“确实没有数据”和“读取过程坏了”。
def run_import(path: Path) -> None:
try:
import_records(path)
except Exception:
logger.exception("导入失败,文件为 %s", path)
raise直接用 'w' 写正式配置有一个明显风险:打开时旧内容已经被清空,写到一半如果程序崩溃、序列化失败或磁盘空间不足,留下的就是半份文件。
更稳妥的基础流程是:
在目标文件同一目录创建临时文件。同一目录能提高临时文件与目标位于同一文件系统的概率,这是后续原子替换的重要条件。
把完整新内容写入临时文件,刷新 Python 缓冲;对持久性要求较高时,再请求操作系统同步临时文件。
写入全部成功后,用替换操作把临时文件移动到正式路径。支持原子替换的平台上,其他读取者看到的是完整旧版本或完整新版本,不会看到写到一半的正文。
任一步失败都删除遗留临时文件,并继续抛出异常,让调用者知道保存没有成功。旧的正式文件在替换前仍保持不动。
import json
import os
import tempfile
from pathlib import Path
def save_json_safely(path: Path, data: object) -> None:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
temp_path = None
try:
with tempfile.NamedTemporaryFile(
mode=
allow_nan=False 会拒绝 NaN 和无穷大这些不符合严格 JSON 规范的浮点值,避免把兼容性问题写进正式配置。临时文件必须先退出 with 再替换,这也兼顾了对打开文件替换有限制的平台。
保存前还应先完成业务验证。原子替换能保证“整份换上去”,却无法阻止你把一份结构完整但含义错误的配置完整地换上去。比较清楚的顺序是:先验证内存对象,再尝试序列化和写入临时文件,最后替换。验证不应放在正式文件已经被替换之后。
如果产品需要给用户恢复机会,可以在替换前保留一份上一版本备份,但备份也有代价:什么时候轮换、最多保留几份、备份失败是否阻止本次保存,都要明确。不要简单地每次复制出一个永久不清理的 .bak,磁盘最终仍可能被占满。对入门项目,先保证失败时旧文件不动,并把异常交给调用者;有真实恢复需求后再加入可测试的备份策略。
测试安全保存时,不要只测成功。可以准备无法序列化的对象,确认正式文件仍是旧内容;把目标目录设成无写权限的测试位置,确认错误没有被吞;模拟损坏 JSON,确认加载函数不会把它误判为首次运行。失败路径被实际跑过,才知道设计不是停留在纸面上。

先完整写入临时文件,再用 os.replace 替换目标文件,能避免直接覆盖留下半成品。
这套方法主要防止读者看到“半份新文件”,并保留替换前的旧文件。它不能验证业务数据是否正确,也不能自动解决两个进程同时保存时谁覆盖谁的问题;跨文件系统移动时,替换还可能失败。若断电一致性要求非常严格,还要研究目录同步、文件系统保证、备份和恢复策略。入门项目先做到“完整写临时文件,成功后再替换”,已经比直接覆盖可靠很多。
在故障模拟器里选择不同的中断时刻,对比直接用 'w' 覆盖和“临时文件加替换”两条路线。注意正式文件何时改变,以及失败后读者最终看到的是旧版本、完整新版本,还是半份数据。
最后把前面的知识合在一起。需求是:配置不存在时使用默认值;文件存在但编码、JSON 或字段有问题时,明确报错;保存时使用临时文件替换,不能假装成功。
import json
from pathlib import Path
class SettingsError(Exception):
"""学习计划配置不可用。"""
DEFAULT_SETTINGS = {
"student": "新同学",
"daily_minutes": 30,
"reminder": True,
}
def validate_settings(data: object) -> dict:
if not isinstance
这个例子有几个刻意做出的区分。文件缺失被定义成可恢复的首次使用,所以返回默认值;权限、编码和 JSON 损坏会隐藏底层术语,转换成统一的 SettingsError,但用异常链保留原因;解析完成后仍做字段验证;展示逻辑放在 else,不会被加载阶段的处理器误捕获。
如果接下来要保存修改后的 settings,可以先调用 validate_settings(settings),再交给前面的 save_json_safely()。先验证、后序列化到临时文件、最后替换正式文件,整个数据流就闭合了。
请补全一个 load_profile(path) 函数,满足下面的规则:
{"name": "访客"};ProfileError;name 不是非空字符串时,也抛出 ProfileError;下面的函数看起来短,但有三个问题。先自己找一遍:
def save(path, data):
try:
with open(path, "w") as file:
json.dump(data, file)
except Exception:
print("保存完成")到这里,你可以用一条实际的数据路径检查自己是否掌握了本章:从确定 Path 的基准开始,用 with 打开 UTF-8 文件,选择合适的读取方式,解析并验证 JSON;遇到预期错误时捕获具体异常,需要跨层说明时保留异常链;保存时先写完临时文件再替换。文件成功写到磁盘只是结果,知道失败时旧数据还在、错误也没有被藏起来,才是这套代码真正可靠的地方。
最后执行 finally。即使没有匹配的异常处理器,异常也会等 finally 完成后再继续向外传播。
这份实现没有用 except Exception 兜底。权限不足、目录误当文件等问题会保留原来的异常类型向上交,由更了解运行环境的调用者决定怎样处理。