Skip to content
This page has been auto-translated and may contain errors.View in English

文件与异常 ​

大多数真正干活的程序都会接触文件系统:读取配置、写入结果、加载数据。而当出问题时,Python 会抛出异常(exception),也就是发生了意外情况的信号。这一章会讲两件事:如何把数据读进文件、写出文件,以及如何写出能优雅处理错误而不是直接崩溃的代码。

文件 I/O 和异常处理是让程序可靠运行的两大机制。open() 让你能访问文件系统;with 确保即便出错,文件也会被正确清理。try/except 能捕获特定类型的异常,让你从错误中恢复而不是直接崩溃。二者结合起来,就是任何无人值守脚本的基础。

文件和异常,正是脚本与混乱的外部世界打交道的地方:路径不存在、磁盘写满、输入内容解析不了。open() 会返回一个文件对象,而 with 能保证即便代码块内部抛出异常,文件也一定会被关闭。异常就是 Python 用来发出失败信号的机制:一旦抛出,它会沿着调用链一路向上传播,途中第一个匹配的 except 会接住它。这一章要讲的原则是:只捕获你真正能处理的那些具体失败,其余的要让它大大方方地暴露出来,而不是把所有异常都吞掉,交出一个悄无声息就失败的脚本。

打开文件 ​

open() 会打开一个文件并返回一个对象,你可以用它来读取或写入内容。你需要告诉它文件路径,以及你想对文件做什么(读取、写入还是追加)。用完文件一定要关闭;with 语句会自动帮你完成这件事。

open(path, mode) 会返回一个文件对象。模式字符串决定了访问方式:"r" 读取,"w" 写入(会先清空文件),"a" 在末尾追加内容。当文件不是文本文件时,加上 "b" 表示二进制模式。从一开始就值得养成的一个习惯:处理文本文件时传入 encoding="utf-8"。不传的话,Python 会使用机器的默认编码,这样一来,一个在你自己电脑上读起来正常的文件,在别人的机器上打开可能就会出现乱码。

open() 会向操作系统申请一个文件句柄,并返回一个包装了这个句柄的文件对象。模式参数同时决定了访问方式("r"、"w"、"a")以及你拿到的是文本还是原始字节("b")。真正会坑人的参数是 encoding:它决定了磁盘上的字节该如何映射成字符。不设置它,Python 就会退回到机器的本地默认编码,而不同平台的默认编码是不一样的,所以一台机器上写出的文件,在另一台机器上可能会解码出错。除非有特别的理由,处理文本时始终传入 encoding="utf-8",大多数意外情况就会消失。

python
f = open("data.txt", "r")    # "r" = 读取
content = f.read()
f.close()

这里的 "r" 就是模式(mode):

模式含义
"r"读取。文件必须存在。默认模式。
"w"写入。创建文件或覆盖已有文件。
"a"追加。在末尾添加内容,不会清空原内容。
"x"创建。如果文件已存在则失败。
"r+"读写。
"b"二进制。可加到任意模式后面:"rb"、"wb"。

用完文件一定要调用 .close()。忘记调用会让文件一直处于打开状态,还可能丢失那些原本正等待写入的数据。更可靠的做法是使用 with 语句。

Juno打开文件open(path, mode) 会给你一个文件对象,模式则说明了你想做什么:"r" 读取,"w" 写入(会把原有内容清空),"a" 在末尾追加。我曾经想用 "a",结果不小心用了 "w",一个文件就这么没了,所以打字之前先在脑子里想清楚要用哪个模式。
Juno打开文件open(path, mode) 返回一个文件对象:"r" 读取,"w" 覆盖,"a" 追加,另外还有 "b" 表示二进制。处理文本文件时传入 encoding="utf-8",这样在任何机器上读取结果都一致,关闭的事就交给 with 来处理。
Juno打开文件open() 会给你一个包装了操作系统句柄的文件对象;模式决定了访问方式以及是文本还是字节。最容易在不同环境间出问题的设置是 encoding:不设置它就会继承机器的本地默认编码,所以处理文本时养成写 encoding="utf-8" 的习惯。有 with 可用时,不要手动调用 .close()。

with 语句 ​

with open(...) 会替你管理文件,在缩进代码块结束时自动关闭文件,即便中途出错也一样。始终使用 with open(...),而不是手动调用 open()/close()。它更安全,也是标准写法。

with 是 Python 的**上下文管理器(context manager)**语法:一个小小的协议,某个对象会在代码块开始时收到一次“准备”调用,在结束时收到一次“收尾”调用。对文件来说,“准备”阶段会返回打开的文件,“收尾”阶段会关闭它。它的好处在于:即便代码块内部抛出了异常,收尾操作依然会执行,这样你就能获得 try/finally 那样的清理保证,却不用在每个文件操作外面都手写一遍。

上下文管理器(任何可以放在 with 后面的对象)定义了两段行为:进入时该做什么,退出时该做什么。with open(...) as f 会执行“进入”步骤,把文件绑定到 f 上,并注册“退出”步骤,Python 会在代码块结束时执行这个退出步骤,无论是正常结束还是因为抛出了异常。对文件来说,“退出”步骤就是 close()。这里有两点实用的补充。你可以在一条语句里打开多个文件,with open(a) as f, open(b) as g:,两者会按顺序依次关闭。而且“退出”步骤可以选择把异常吞掉,contextlib.suppress(FileNotFoundError) 这类工具正是靠这个机制实现的,所以要知道:如果上下文管理器是这样设计的,一个 with 代码块是可能悄悄吸收掉一个错误的。

python
with open("data.txt", "r") as f:
    content = f.read()

# 到这里 f 已经保证被关闭

with 是 Python 的上下文管理器语法:它会替你运行设置和收尾代码,这里就是打开文件并可靠地关闭文件。你不需要知道它内部是怎么实现的,也能把它和 open() 配合使用。

Junowith 语句with open(...) as f 会打开文件,让你在缩进代码块里用 f 操作它,代码块结束时会自动帮你关闭文件,即便中途出了问题也一样。每次都用它,而不要自己调用 .close(),这样能少一件要记住的事。
Junowith 语句with 在代码块打开时执行设置,关闭时执行清理,而且即便发生异常,清理也照样会执行。对文件来说,这个清理就是 close(),于是你不用手写 try/finally 也能获得同样的安全保证。任何时候都优先用它,而不是手动调用 open()/close()。
Junowith 语句 上下文管理器会在退出时关闭文件,不管代码块是正常结束还是抛出了异常,所以 with 取代了手写的 try/finally 清理逻辑。用一个 with 就能同时打开多个资源。记住退出步骤是可以吞掉异常的,contextlib.suppress 就是专门干这个的,遇到 with 代码块把错误吞了,要能认出这是怎么回事。

读取文件 ​

有三种读取方法。.read() 把整个文件读成一个字符串。.readline() 读取一行。直接对文件对象做迭代则是逐行读取,对大文件来说这是效率最高的方式,因为它不会把整个文件一次性加载进内存。

.read() 会把整个文件一次性加载进内存,变成一个字符串。.readline() 读取一行,包括换行符。.readlines() 会返回一个包含每一行的列表。对于可能很大的文件,直接用 for line in f 对文件对象做迭代:它每次只取一行,绝不会同时把整个文件都放在内存里,这样无论文件多大,内存占用都能保持平稳。

.read() 会把整个文件作为一个字符串返回,所以它的内存开销和文件大小成正比:对一个配置文件来说没问题,但对一个几个 GB 的日志文件就是个大麻烦。对文件对象做迭代,即 for line in f,每一步只读一行,读完就丢弃,处理下一行之前不会保留上一行,所以无论文件多长,内存占用都保持平稳。这是面对未知大小文件时该默认采用的模式。.readlines() 看起来很方便,但它会一次性把所有行都构造成一个列表,内存开销和 .read() 完全一样,相比循环没有任何优势。能避免那个经典内存溢出问题的原则是:优先用 for line in f 循环,只有确定文件很小时才调用 .read()。

python
with open("data.txt", "r") as f:
    content = f.read()          # 整个文件作为一个字符串

with open("data.txt", "r") as f:
    first_line = f.readline()   # 一次读一行

with open("data.txt", "r") as f:
    lines = f.readlines()       # 行组成的列表,每行结尾带 "\n"

对大文件来说,逐行读取比一次性全部加载更高效:

python
with open("big_file.txt", "r") as f:
    for line in f:              # 直接对文件迭代,节省内存
        print(line.strip())     # strip() 去掉行尾的换行符

直接对文件对象迭代(for line in f)是读取大文件最高效的方式。

Juno读取文件.read() 会把整个文件读成一个字符串,.readline() 只读一行。文件可能很大时,改用 for line in f 循环,它一次只读一行,永远不会把全部内容一起塞进内存。每行后面的 .strip() 会去掉末尾的换行符。
Juno读取文件.read() 一次性加载全部内容,小文件没问题,大文件就有风险。默认应该用 for line in f:它以流式方式一次读一行,无论文件多大,内存都保持平稳。.readlines() 看起来整洁,但占用的内存和 .read() 一样多。
Juno读取文件.read() 和 .readlines() 都会把整个文件保存在内存里,所以内存占用会随文件大小增长。for line in f 循环一次读一行、读完就丢,内存占用保持平稳,正是这一点避免了在没提前估算大小的文件上出现内存溢出的意外。默认用循环,只有确定文件很小时才用 .read()。

写入文件 ​

"w" 模式如果文件已存在,会把它整个覆盖掉。"a" 模式则是在末尾追加内容。.write() 不会自动帮你加换行符;需要在每行末尾自己显式加上 "\n"。要一次性写入多行,可以用 "\n".join() 把它们拼接起来。

.write(s) 写入一个字符串,和 print() 不同,它不会额外添加任何内容,不加空格也不加换行符,换行符要你自己加上去。.writelines(lines) 会把列表中的每个字符串依次写入,同样不会添加任何分隔符,所以换行符必须已经在你的字符串里了。要留意模式的选择:"w" 会在文件被打开的那一刻就清空它,甚至在你写入任何内容之前,所以想用 "a" 却误用了 "w",就会把原有内容全部丢掉。

写文件这件事有两个真正会导致 bug 的细节。第一,你写入的数据不一定立刻落到磁盘上:操作系统会把它先放在缓冲区里,分批写入,而正是 .close()(with 会替你调用它)才能保证一切都真正落盘。如果一个长时间运行的进程一直在写却从不关闭,输出的末尾部分可能就一直留在缓冲区里,一旦崩溃就会丢失,所以在数据必须安全落盘的那些时刻,要主动关闭文件或调用 .flush()。第二,模式决定了破坏的程度:"w" 会在打开的那一瞬间就清空文件,甚至还没写入任何内容,所以本想用 "a" 却用了 "w",就会毫无征兆地毁掉之前的内容。.writelines(lines) 不会自己添加任何分隔符,换行符必须已经在字符串里了。

python
with open("output.txt", "w") as f:
    f.write("Hello, world\n")

with open("output.txt", "a") as f:
    f.write("Another line\n")

"w" 如果文件已存在会把它整个覆盖掉。"a" 会在末尾追加内容。

f.write() 不会自动添加换行符,所以要显式加上 "\n"。要一次写入多行:

python
lines = ["第一行", "第二行", "第三行"]

with open("output.txt", "w") as f:
    f.write("\n".join(lines) + "\n")
Juno写入文件"w" 会创建或覆盖文件,"a" 在末尾追加内容而不清空原有内容。.write() 不会替你加换行符,所以每行末尾要自己加上 "\n"。要写入一批行,先用 "\n".join(lines) 把它们拼好。
Juno写入文件"w" 一打开就清空文件,"a" 才是追加,选错模式会悄无声息地丢掉原有内容。.write() 什么都不加,连换行符也没有,要自己补上 "\n"。.writelines() 写入一个列表同样不加任何分隔符,换行符必须提前放进字符串里。
Juno写入文件"w" 一打开就立刻清空文件,本想用 "a" 却用了它,会毫无征兆地毁掉之前的内容。写入的数据会先留在缓冲区,直到 .close()(with 会调用它)才会真正刷新落盘,所以一直写却从不关闭的进程一旦崩溃,末尾数据可能就丢了。.write() 和 .writelines() 都不会加换行符,这事得你自己管。

异常 ​

当 Python 遇到自己无法处理的问题时,会抛出一个异常(exception):一个描述出了什么错、错在哪里的错误对象。如果你不处理它,程序就会崩溃并打印出一段 traceback。下表列出了你会遇到的最常见异常。

异常是一种对象,而各种异常之间构成了一棵家族树:像 FileNotFoundError 这样具体的异常,属于更宽泛的一类(OSError),而几乎所有你会捕获的异常都属于 Exception 这个大类。正是这棵树让捕获机制能起作用:捕获一个父类型,也会同时捕获它的所有子类型。异常一旦被抛出,Python 就会放弃当前这一行,沿着导致这里的调用链一路向上回溯,寻找匹配的 except。如果一个都找不到,程序就会停止运行,并打印出一段traceback:从错误发生的地方一直回溯到最顶层的调用列表。

异常构成了一个类的层级结构(一棵类型的家族树),而捕获异常时,起决定作用的正是这棵树:针对父类型的 except 会捕获它下面的每一种类型,所以 except Exception 是一张大网,而 except FileNotFoundError 是一个精准的小网。这棵树里最值得记住的一点是:KeyboardInterrupt(用户按下 Ctrl-C)和 SystemExit(一次干净的退出请求)是和 Exception 平级的,而不在它下面。这是有意为之的设计:意味着一个宽泛的 except Exception 会让这两者顺利通过,所以即便在一个"全捕获"语句内部,Ctrl-C 依然能让你的程序停下来。这正是为什么你应该用 except Exception,而永远不要用裸的 except: —— 后者会把这两者也一起捕获,把想要退出的用户困在程序里出不来。

你会遇到的常见异常:

异常发生场景
FileNotFoundErroropen() 找不到文件
ValueError函数拿到了类型正确但内容不对的值,例如 int("abc")
TypeError类型完全不对,例如 "hello" + 5
KeyError字典中不存在这个键
IndexError列表索引超出范围
ZeroDivisionError除以零
AttributeError对象没有这个属性或方法
Juno异常 异常就是 Python 在告诉你出了问题。有几个你会经常遇到:FileNotFoundError、ValueError、KeyError、TypeError。如果不处理它们,程序会停下来并打印一段 traceback,看起来挺吓人,但它其实是一张地图,直接指向出错的地方。
Juno异常 异常是按家族树排列的对象,捕获父类型也会捕获它的子类型。异常一旦被抛出,Python 会沿调用链向上寻找匹配的 except,找不到就打印一段 traceback。了解常见的名字,比如 ValueError、KeyError、FileNotFoundError,能让你捕获到正确的那一种。
Juno异常 类型层级结构才是重点:except Exception 是一张大网,具体的类型是一个精准的小网。KeyboardInterrupt 和 SystemExit 与 Exception 平级,不在它下面,所以 Ctrl-C 依然能穿过 except Exception。这正是要用它而不用裸 except: 的原因,裸 except: 会把想退出程序的用户困住。

try / except ​

把可能失败的代码放进 try 代码块。如果发生异常,匹配的 except 代码块会处理它,而不是让程序崩溃。要具体指明你捕获的是哪种异常:用裸的 except: 捕获所有异常会掩盖真正的 bug。

try/except 捕获某个具体的异常类型,并运行处理代码,而不是让程序崩溃。指明类型很重要:只捕获你真正能恢复的那种,这样一个无关的 bug 才会响亮地暴露出来,而不是被悄悄吞掉。想读取异常的信息时,用 as e 把它绑定到一个名字上。可以为不同类型列出多个 except 子句,Python 会使用第一个匹配的那个。

当抛出的异常是 except SomeType 声明的那个类型,或者是它在树中更下层的某个子类型时,这个 except 就会匹配上,这也是为什么捕获父类型会连带捕获它所有的子类型。Python 会从上到下依次尝试每个 except,在第一个匹配处停下,所以要把具体的类型写在前面、宽泛的类型写在后面:如果一个宽泛的子句放在最前面,它会把所有异常都接住,下面那些更精确的子句就永远不会运行。能让 try/except 不至于掩盖 bug 的原则是:只捕获你能处理的最窄的那个类型,把 try 代码体压缩到那一行真正可能抛出异常的地方,让其余的一切都自然传播出去。一个把二十行代码都包进去的 try,会把一次预期中的失败,变成掩盖另外十九个意外问题的遮羞布。

python
try:
    value = int("abc")
except ValueError:
    print("这不是一个有效的数字")

要具体指明你捕获的是哪种异常。用裸的 except: 捕获所有异常会掩盖 bug:

python
# 不好,会捕获一切,包括编程失误
try:
    result = do_something()
except:
    pass

# 好,只捕获你预期到、也真正能处理的错误
try:
    result = do_something()
except FileNotFoundError:
    print("找不到文件")
Junotry / except 把可能失败的代码放进 try 代码块,匹配的 except 会运行起来,而不是让程序崩溃。指明你预期的异常,比如 ValueError,而不是用裸的 except:,后者会捕获一切,把真正的 bug 都藏起来。捕获你真正能处理的,让其余的暴露出来。
Junotry / excepttry/except 捕获指定类型并恢复运行,而不是崩溃;as e 能拿到错误信息。捕获你能处理的具体类型,这样无关的 bug 依然会暴露出来。让 try 代码体保持精简,包进去二十行会把一次预期中的失败变成盖住其余问题的遮羞布。
Junotry / exceptexcept 会匹配它声明的类型及其任何子类型,Python 会采用第一个匹配的子句,所以要把具体类型排在前面、宽泛类型排在后面,否则宽泛的那个会把后面的都盖住。捕获最窄的那个类型,把 try 压缩到真正可能抛出异常的那一行,让其余的自然传播出去。一个宽泛的 try 掩盖的问题,远比你原本想处理的那一个要多得多。

捕获多种异常 ​

你可以用不同的 except 代码块分别处理不同的错误类型,也可以用元组在一个代码块里捕获多种类型。as e 这部分能让你拿到错误信息。

为不同类型堆叠多个 except 子句,Python 会从上到下依次检查,采用第一个匹配的。想用同样的方式处理多种类型,就把它们放进一个元组分组:except (ValueError, ZeroDivisionError)。as e 会把异常绑定起来,方便你读取它的信息。顺序很重要:把具体的类型放在宽泛的类型前面,否则前面一个宽泛的子句会把一切都接住,下面的子句就永远不会运行。

顺序是这里的陷阱。Python 会采用第一个类型匹配的 except,而宽泛的类型会匹配它更具体的近亲类型,所以把 except Exception 写在 except ValueError 前面,就意味着 ValueError 那个子句成了永远不会执行的死代码。具体的放前面,宽泛的放最后。用 except (A, B) as e 把响应方式相同的类型分成一组。当你捕获一个错误只是为了补充上下文再继续往外传时,用 from 重新抛出新的异常:raise ValueError("bad config") from e。这样在 traceback 里,原始错误依然能在新错误下面看到,报告里同时展示了高层含义和底层原因,而不是用一个取代另一个。

python
try:
    data = int(user_input)
    result = 100 / data
except ValueError:
    print("不是一个数字")
except ZeroDivisionError:
    print("不能除以零")

或者用元组捕获多种类型:

python
except (ValueError, ZeroDivisionError) as e:
    print(f"输入错误:{e}")

as e 会把异常对象绑定到一个名字上,方便你查看错误信息。

Juno捕获多种异常 给每种错误类型单独写一个 except 代码块,Python 会运行第一个匹配的那个。想用同样的方式处理几种类型,就把它们列进一个元组:except (ValueError, ZeroDivisionError)。as e 这部分会把错误对象交给你,方便你打印它的信息。
Juno捕获多种异常 多个 except 子句从上到下依次运行,第一个匹配就采用,所以要把具体类型列在宽泛类型前面,否则宽泛的那个会把后面的都盖住。一个元组,except (A, B),能用一个代码块处理多种类型。as e 会把异常绑定起来,方便读取它的信息。
Juno捕获多种异常 具体的子句要放在前面:宽泛的类型放在具体的类型上面,会让具体那个子句变成死代码。用 except (A, B) as e 把响应方式相同的类型分成一组。重新抛出以补充上下文时,用 raise NewError("...") from e,这样原始原因会保留在新错误下面显示在 traceback 里,而不是直接消失。

else 和 finally ​

else 只会在没有发生异常时运行。finally 总会运行,不管有没有发生异常。finally 适合用来做那些无论如何都必须完成的清理工作。

else 里放的是只有在 try 代码体没有抛出任何异常时才该运行的代码。把它放在 try 之外,能让意图更清晰:你不会不小心把成功路径上的代码也纳入自己的 except 捕获范围。finally 是一种清理上的保证,它无论如何都会运行——无论代码块是成功了、抛出了异常、异常被捕获了,还是甚至遇到了一个 return。正因如此,它是释放那些不管结果如何都必须放手的资源的合适场所。

else 只在 try 代码体干净地结束时运行,这样能把成功路径上的代码留在 try 之外,避免它意外触发你自己写的某个 except 子句。finally 是无条件运行的:成功之后运行,捕获了异常之后运行,一个未被捕获的异常向上传播时也会运行,甚至 try 里出现了 return 也照样运行。最后这种情况藏着一个值得留意的陷阱:finally 里的 return 会覆盖掉 try 里的 return,更糟的是,它还会悄悄吞掉一个正在传播中的异常,导致这个错误没留下任何痕迹就消失了。让 finally 只做清理工作,永远不要从里面 return。对文件来说,with 已经给了你这份保证,所以 finally 主要该留给那些没有对应上下文管理器、你却持有着的资源。

python
try:
    with open("data.txt") as f:
        content = f.read()
except FileNotFoundError:
    print("找不到文件,使用默认值")
    content = ""
else:
    print("文件加载成功")
finally:
    print("文件加载尝试结束")   # 总会运行

即便你已经在用 with 处理文件,finally 在清理工作上(关闭连接、释放锁)依然最为有用。

Junoelse 和 finallyelse 只在 try 没有抛出任何异常时运行,是放置成功路径其余代码的整洁去处。finally 每次都会运行,无论有没有异常,这让它成为存放无论结果如何都必须完成的清理工作的地方。对文件来说,with 已经涵盖了清理工作,所以你用到 finally 的机会会比想象中少。
Junoelse 和 finallyelse 只在干净的那条路径上运行,让成功路径的代码留在 try 之外,这样它就不会触发你自己的 except。finally 是无条件运行的,甚至能跨过一个 return,所以它是你的清理保证。对文件来说,with 已经做到了这一点,所以把 finally 留给那些没有上下文管理器的资源。
Junoelse 和 finallyelse 是那条干净路径上的代码,留在 try 之外,这样就不会触发你的 except 子句。finally 无论如何都会运行,但永远不要从里面 return:那种覆盖会吞掉一个正在传播的异常,让错误悄无声息地消失。让 finally 只做清理工作,而且大多数情况下只有在没有 with 可用时才用它。

raise ​

你可以用 raise 自己主动抛出异常。这是让你的函数向调用者清楚地发出问题信号,而不是悄悄返回一个错误结果的方式。

raise ExceptionType("message") 会从你自己的代码里抛出一个异常,这是函数报告输入错误的方式,而不是带着一个错误的值硬撑下去。在 except 代码块里,裸的 raise(不带参数)会把你刚捕获的异常重新抛出,适合在你想记录日志或做出反应之后,仍然让它继续向上传播的场合。有意地抛出异常,能让一个函数的失败模式成为它对外契约的一部分,让调用者可以按名字捕获并处理它们。

raise SomeError("message") 是有意地报告一次失败,当函数拿到了它无法妥善处理的输入时,这正是正确的做法:响亮地拒绝,胜过返回一个日后会以谜团形式冒出来的错误答案。在 except 内部,裸的 raise 会重新抛出你捕获到的异常,并保留它原始的 traceback,这样你就能先记录日志、再重新抛出,而不会抹掉它原本的来龙去脉。想把一个底层错误转译成业务层错误时,用 from:raise ConfigError("bad settings") from e 会让原始错误保留在新错误的下方一起出现在报告里,让读者同时看到含义和原因。这里的经验法则是:重新抛出是为了补充上下文,而不是为了把它藏起来。

python
def divide(a, b):
    if b == 0:
        raise ValueError("不能除以零")
    return a / b

这能让你的函数明确表达自己的预期,并向调用者清楚地发出问题信号。

Junoraiseraise ExceptionType("message") 让你自己的代码大声地标出问题,而不是带着一个错误的值继续硬撑下去。这是函数表达"我处理不了这个"的方式,好让调用者来应对。早一点、清楚地报错,胜过日后才冒出来的一个错误答案。
Junoraiseraise ExceptionType("message") 从你的代码里报告一次失败,让函数的错误情形成为调用者使用它时要考虑的一部分。except 里裸的 raise 会重新抛出你捕获到的异常,保留原始的 traceback,这样你可以先记录日志,再让它继续向上传播。
Junoraise 当输入无法妥善处理时,就有意地抛出异常:响亮地拒绝,胜过日后才作为谜团冒出来的错误答案。裸的 raise 会带着原始 traceback 重新抛出。想把一个底层错误转译成业务层错误时,用 raise DomainError("...") from e,让原因依然可见,重新抛出是为了补充上下文,而不是为了掩埋它。

自定义异常类 ​

对于更大的程序,你可以通过继承 Exception 来定义自己的异常类型。这样调用者就能把你特有的错误和其他种类的错误分开单独捕获。

自定义异常就是你自己写的、继承自 Exception 的类(它会成为一种新的异常,所以能顺理成章地融入同一棵家族树)。这样做的好处是,调用者能拿到一个可以精确捕获的名字,和内置错误区分开来。对于一组相关的失败情形,可以做一个基类,再为每种具体情况分别写子类:调用者可以捕获基类来处理整个组,也可以捕获某个子类来单独处理其中一种。

要继承 Exception,而不是 BaseException(那个根类还涵盖了 Ctrl-C 和关机操作,你不会想让你的错误和它们归为一类)。定义自己的异常类型而不是重用 ValueError 的理由在于家族树:建一个像 PaymentError 那样的基类,再为每种模式写子类(InsufficientFundsError、CardDeclinedError),调用者就能捕获 PaymentError 来处理整个类别的问题,或者捕获某个子类来单独处理一种情况,而不需要去解析错误信息字符串。给这个类写一个带有相关字段(比如账户、金额)的 __init__,这样处理代码就能通过代码直接检查发生了什么,而不用去抓取文本,这让这个错误变成一个程序可以据此采取行动的对象,而不只是拿来打印的东西。

python
class InsufficientFundsError(Exception):
    pass

class BankAccount:
    def __init__(self, balance):
        self.balance = balance

    def withdraw(self, amount):
        if amount > self.balance:
            raise InsufficientFundsError(
                f"无法取出 {amount},当前余额为 {self.balance}"
            )
        self.balance -= amount
python
try:
    account.withdraw(1000)
except InsufficientFundsError as e:
    print(f"交易被拒绝:{e}")
Juno自定义异常类 通过继承 Exception 来创建你自己的错误类型,一个空的类体就足够起步了。它能给调用者一个属于你自己、和内置错误区分开的名字来捕获,让你程序里的问题能自成一体。
Juno自定义异常类 自定义异常就是一个继承自 Exception 的类,能给调用者一个精确的名字来捕获。对于相关的失败情形,做一个基类,再为每种情况分别写子类:捕获基类处理整个组,捕获子类处理单独一种。你还可以加字段,携带比错误信息字符串更多的细节。
Juno自定义异常类 要继承 Exception,而不是 BaseException,这样你的错误就不会和 Ctrl-C 混为一谈。定义自己的异常类型的理由就在于这棵树:一个 PaymentError 基类配上具体的子类,能让调用者处理整个类别或单独一种情况,而不需要去解析错误信息文本。把相关字段放进 __init__,这样处理代码就能基于数据而不是字符串来行动。

JSON ​

JSON 是通用的数据格式:API、配置文件、数据导出,都在用它。Python 的 json 模块可以直接处理它。json.load() 会从文件中读取 JSON,转换成 Python 的字典或列表。json.dump() 则会把字典或列表以 JSON 格式写回文件。

这几个函数是按操作对象来分组的。json.load() / json.dump() 处理的是文件对象,json.loads() / json.dumps()(名字里带 s 的那两个)处理的是内存里的字符串。当输出内容是给人看的时候,给 dump/dumps 传入 indent=2,否则输出会全部挤在一行里。无效的 JSON 会抛出 json.JSONDecodeError,它属于 ValueError 的一种,所以捕获 ValueError 的处理代码已经涵盖了这种情况。

这四个名字其实是两种操作乘以两种数据来源:load/dump 对应文件,loads/dumps 对应字符串(这个 s 就是"string"的意思)。真正会在生产环境中坑人的细节,是 JSON 表达不了哪些东西。它没有日期、set 或 Decimal 这样的概念,所以 json.dumps(datetime.now()) 会抛出 TypeError。处理方式是传入 default=,这是一个函数,dump/dumps 会在遇到不认识的值时调用它,这个函数应该返回一个 JSON 能承载的东西,通常就是 str(value)。另一条值得记住的规则是:数字在往返转换后会变成 float,所以你为了精确而存成 Decimal 的值,读回来时会变成 float,精确度也就丢了。永远不要把金额或任何需要精确小数的东西(参见数字那一章)当作普通的 JSON 数字来存储。

从文件中读取 JSON:

python
import json

with open("config.json", "r") as f:
    config = json.load(f)    # 把 JSON 解析成 Python 的 dict/list

print(config["setting"])

把 JSON 写入文件:

python
import json

data = {"name": "小明", "score": 87, "active": True}

with open("output.json", "w") as f:
    json.dump(data, f, indent=2)    # indent= 让输出更易读

JSON 与 Python 类型的对应关系:

JSONPython
对象 {}dict
数组 []list
字符串 ""str
数字int 或 float
true / falseTrue / False
nullNone

不涉及文件、在 JSON 字符串和 Python 对象之间直接转换:

python
import json

# 字符串转 Python 对象
data = json.loads('{"name": "小明", "score": 87}')

# Python 对象转字符串
text = json.dumps({"name": "小明", "score": 87}, indent=2)

json.load() 从文件对象中读取。json.loads()(带一个"s")从字符串中读取。

JunoJSONjson.load(f) 把文件里的 JSON 变成 Python 的字典和列表,json.dump(data, f) 则把它们写回去。带 s 的版本,json.loads 和 json.dumps,做的是同一件事,只是对象换成了字符串而不是文件。想让输出更易读时加上 indent=2。
JunoJSONload/dump 处理文件,loads/dumps 处理字符串,这个 s 就是字符串的意思。indent=2 能让输出更易读。错误的 JSON 会抛出 json.JSONDecodeError,它是一种 ValueError,所以捕获 ValueError 已经涵盖了解析失败的情况。
JunoJSON 两种操作乘以两种来源:load/dump 对应文件,loads/dumps 对应字符串。JSON 没有日期、集合或 Decimal,所以序列化这些东西会抛出 TypeError,除非你传入 default=。而且数字在往返转换后会变成 float,所以永远不要把金额当作普通的 JSON 数字存储,它在读回来的路上会丢失精确度。

实践一下 ​

一个小游戏的存档/读档模式:把状态写成 JSON,下次运行时读回来,如果还没有存档文件就退回默认值:

python
import json

SAVE_FILE = "save_game.json"

def save_game(player_data: dict) -> None:
    with open(SAVE_FILE, "w") as f:
        json.dump(player_data, f, indent=2)
    print("游戏已保存。")

def load_game() -> dict:
    try:
        with open(SAVE_FILE, "r") as f:
            return json.load(f)
    except FileNotFoundError:
        print("未找到存档文件,重新开始。")
        return {"name": "玩家", "score": 0, "level": 1}

state = load_game()
state["score"] += 50
save_game(state)

加载配置文件并保存结果,针对每种失败模式分别做异常处理:

python
import json

def load_config(path: str) -> dict:
    try:
        with open(path, "r") as f:
            return json.load(f)
    except FileNotFoundError:
        raise FileNotFoundError(f"未找到配置文件:{path}")
    except json.JSONDecodeError as e:
        raise ValueError(f"{path} 中的 JSON 无效:{e}")

def save_results(results: list[dict], path: str) -> None:
    with open(path, "w") as f:
        json.dump(results, f, indent=2)
    print(f"已将 {len(results)} 条结果保存到 {path}")

config = load_config("experiment.json")
results = [{"epoch": 1, "loss": 0.82}, {"epoch": 2, "loss": 0.61}]
save_results(results, "results.json")

一个结构化的日志写入器,把带时间戳的记录追加到文件中,顶层还有一个处理器负责捕获并记录意外的失败:

python
import json
from datetime import datetime

LOG_FILE = "run.log"

def log(level: str, message: str) -> None:
    ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    entry = f"[{ts}] [{level.upper():7}] {message}\n"
    with open(LOG_FILE, "a") as f:
        f.write(entry)

def process(config_path: str) -> None:
    log("info", f"任务开始,配置文件:{config_path}")
    try:
        with open(config_path) as f:
            config = json.load(f)
        log("info", f"已加载配置:{config}")
    except FileNotFoundError:
        log("error", f"未找到配置文件:{config_path}")
        raise
    except json.JSONDecodeError as e:
        log("error", f"配置文件中的 JSON 有问题:{e}")
        raise

try:
    process("config.json")
except Exception as e:
    log("critical", f"任务失败:{e}")

记录日志之后再重新抛出,能为调用者保留原始的 traceback。顶层的 except Exception 会捕获所有漏网的异常,把它记录为 critical 级别,然后让进程干净地退出。