第 16 章 模块、异常与文件¶
配套例题:PIO3 多组_A+B_EOF形式 来源:菜鸟教程 Python3 模块、错误和异常、标准库概览
这一章是第一部分的收尾,讲三件工程性的事:代码怎么组织(模块)、 出错了怎么办(异常)、数据从哪来(文件)。
竞赛视角下,三者的权重差别很大:
- 模块:只需要会
import,不需要自己写包。 - 异常:最重要——OJ 上的 RE(Runtime Error)全部是未捕获的异常, 能快速从异常名反推 bug 位置,是节省调试时间的关键技能。
- 文件:OJ 一律用标准输入输出,
open只在本地对拍时用。
16.1 模块与 import¶
一个 .py 文件就是一个模块,文件名(去掉 .py)就是模块名。
import sys # 导入整个模块,用 sys.stdin
import numpy as np # 起别名
from math import gcd # 只导入一个名字,直接用 gcd
from math import gcd, isqrt, inf # 导入多个
from math import * # ❌ 导入全部,污染命名空间
| 形式 | 使用方式 | 速度 | 建议 |
|---|---|---|---|
import m |
m.f() |
每次调用多一次属性查找 | 一般情况 |
from m import f |
f() |
最快 | 热点循环里的函数(heappush 等) |
from m import * |
f() |
同上 | 永远不要用 |
from math import *的致命之处:它会引入math.pow, 覆盖内置的三参数pow。之后写pow(a, b, mod)直接TypeError: pow() takes no keyword arguments或结果变成float。 同理它还会覆盖e、gamma、log等常见变量名。永远显式导入。
在 \(10^6\) 级别的循环里,from heapq import heappush 比 heapq.heappush 快约 10%——
因为省掉了每次的模块属性查找。热点代码值得这么写。
import 的机制¶
Python 做四件事:
- 按
sys.path的顺序查找mymod.py(当前目录 →PYTHONPATH→ 标准库 → site-packages)。 - 编译成字节码,缓存在
__pycache__/mymod.cpython-39.pyc。 - 执行整个模块文件(顶层代码全部跑一遍)。
- 把模块对象绑定到名字
mymod。
两个重要推论:
- 模块只会被执行一次。首次导入后,模块对象就被登记进
sys.modules这个字典; 之后每次import都是先查这张表,命中就直接返回同一个对象。 所以在多个文件里重复import不会有性能损失, 也意味着模块顶层的全局状态是全程序共享的一份。 import会执行顶层代码。所以模块里的可执行逻辑要放进if __name__ == "__main__":里保护起来,否则被别人 import 时会意外运行。
__name__ 在「直接运行」时是 "__main__",在「被 import」时是模块名。
竞赛里写不写
if __name__? 写不写都能过。 但把逻辑放进main()函数必须做——局部变量比全局变量快 20%–30%(见第 1 章)。
包¶
带 __init__.py 的目录就是包(Python 3.3+ 起 __init__.py 也可以省略):
OJ 只接受单文件提交,所以竞赛中永远用不到包。
本地整理模板库时有用:把常用模板放进一个 templates/ 包,写题时复制粘贴。
查看模块内容¶
import math
print(dir(math)) # 列出所有名字
print(math.__file__) # 模块文件路径(内建模块没有这个属性)
help(math.gcd) # 查看文档
本地调试时很方便,OJ 上没意义。
16.2 异常:语法错误 vs 运行时异常¶
| 类别 | 何时发现 | OJ 反馈 |
|---|---|---|
语法错误(SyntaxError、IndentationError) |
编译期,整个文件都不会执行 | Compile Error |
| 运行时异常 | 执行到那一行才发生 | Runtime Error(RE) |
Python 的语法错误检查是「全文件」的——文件末尾有个笔误, 开头的代码也一行都不会执行。这和逐行解释执行的直觉不同。
16.3 try / except / else / finally¶
try:
risky()
except ValueError as e: # 捕获特定异常,e 是异常对象
handle(e)
except (IndexError, KeyError): # 一个 except 捕获多种
handle2()
except Exception as e: # 兜底(不要写裸 except)
handle3()
else:
print("没有异常时执行") # 注意:在 finally 之前
finally:
cleanup() # 无论如何都执行
执行顺序:
| 情况 | try 体 |
匹配的 except |
else |
finally |
|---|---|---|---|---|
| 无异常 | 全部执行 | 跳过 | 执行 | 执行 |
| 有异常且被捕获 | 执行到出错处 | 执行 | 跳过 | 执行 |
| 有异常但没匹配上 | 执行到出错处 | 跳过 | 跳过 | 执行,然后异常继续向上抛 |
try 里 return |
— | — | — | 仍然执行(在 return 生效前) |
else 的意义:把「不会出异常的代码」挪出 try 块,
避免它抛出的异常被同一个 except 误捕获。
不要写裸
except:。它会连KeyboardInterrupt、SystemExit都捕获, 也会把拼写错误(NameError)静默吞掉,让 bug 变得无法定位。 至少写except Exception:。
16.4 竞赛里 RE 的成因对照表¶
OJ 上显示 Runtime Error 时,本质就是「有一个异常没被捕获」。 下表按竞赛出现频率排序,这是本章最该记住的内容:
| 异常 | 典型触发 | 竞赛里的真实原因 |
|---|---|---|
IndexError |
a[i],i 越界 |
数组开小了;下标 0/1 混用;n 和 len(a) 不一致;空列表取 a[0]、a[-1] |
RecursionError |
递归太深 | DFS 深度超 1000(默认上限);忘了写递归出口 |
ZeroDivisionError |
x / 0、x // 0、x % 0 |
除数是「计数结果」而计数为 0;求平均值时 \(n = 0\) |
ValueError |
int("abc");解包个数不符 |
输入行有空行 / 多余空格;a, b = line.split() 时列数不对 |
KeyError |
d[k],k 不存在 |
忘了初始化;应该用 defaultdict 或 .get(k, 默认值) |
TypeError |
类型不匹配 | a[n / 2](/ 返回 float);"1" + 1;给内置名赋了值(sum = 0 后再 sum(a)) |
AttributeError |
对象没这个属性 | 拼错方法名;None.append(...)(a = a.sort() 之后) |
MemoryError |
内存耗尽 | 二维数组开太大;list(range(10**9)) |
OverflowError |
浮点溢出 | math.exp(1000);整数不会溢出 |
StopIteration |
next() 耗尽 |
没给 next 传默认值 |
EOFError |
input() 读到文件末尾 |
EOF 形式的多组数据没处理好 |
UnboundLocalError |
读未赋值的局部变量 | 函数里写全局变量忘了 global(见第 11 章) |
NameError |
名字不存在 | 拼写错误;忘了 import |
异常的继承层次(用于精确捕获)¶
BaseException
├─ SystemExit / KeyboardInterrupt ← 别捕获
└─ Exception
├─ ArithmeticError
│ ├─ ZeroDivisionError
│ └─ OverflowError
├─ LookupError
│ ├─ IndexError
│ └─ KeyError
├─ ValueError
├─ TypeError
├─ NameError → UnboundLocalError
├─ AttributeError
├─ RuntimeError → RecursionError
├─ OSError → FileNotFoundError
├─ EOFError / StopIteration / MemoryError / ImportError
所以 except LookupError: 能同时捕获 IndexError 和 KeyError,
except ArithmeticError: 能捕获除零。
本地调试:让异常暴露完整信息¶
Python 的默认报错信息已经包含文件名、行号、调用链,比 C++ 的段错误友好得多。 看到 RE 不要慌,本地跑一遍样例,报错行号直接指出出错位置。
16.5 raise 与 assert¶
raise ValueError("n 必须为正") # 主动抛出
raise # 在 except 块里重新抛出当前异常
assert n > 0, "n 必须为正" # 断言,失败时抛 AssertionError
自定义异常:
竞赛里
raise和assert的正确用法是「本地自测」:写在本地跑对拍时能快速定位问题。提交前建议删掉—— 断言会带来微小开销,而且一旦触发就是 RE 而不是 WA,反而看不出是哪种错。
(
python -O可以关闭断言,但 OJ 不会加这个参数。)
16.6 异常的性能:EAFP vs LBYL¶
Python 社区有两种风格:
| 风格 | 全称 | 写法 |
|---|---|---|
| LBYL | Look Before You Leap | if k in d: v = d[k] |
| EAFP | Easier to Ask Forgiveness than Permission | try: v = d[k] except KeyError: ... |
性能事实:
| 操作 | 开销 |
|---|---|
进入 try 块(无异常发生) |
几乎为零(Python 3.11 起完全为零) |
| 抛出并捕获一次异常 | 相当于几十到上百次普通运算 |
结论:
- 异常很少发生时,用
try更快(省掉每次的if判断)。 - 异常频繁发生时(比如循环里每次都触发),
try会成为瓶颈。
# ❌ 循环里每次都抛异常 —— 10^5 次异常约 0.1–0.3 秒纯开销
for x in a:
try:
cnt[x] += 1
except KeyError:
cnt[x] = 1
# ✅ 用 defaultdict / Counter,一次异常都不抛
from collections import defaultdict
cnt = defaultdict(int)
for x in a:
cnt[x] += 1
竞赛准则:不要把异常当控制流用。 唯一的例外是「读到 EOF 就结束」,而这个场景其实也有更好的写法——见下面的例题。
16.7 文件读写与 with¶
# encoding 要显式写:不写就跟随操作系统默认值,
# 同一份代码在 Windows(GBK) 和 Linux(UTF-8) 上读出的结果可能不同
with open("in.txt", "r", encoding="utf-8") as f:
s = f.read() # 全部读成一个字符串
# lines = f.readlines() # 读成行的列表(含 '\n')
# for line in f: ... # 逐行迭代,省内存
with open("out.txt", "w") as f:
f.write("hello\n")
print("hello", file=f) # print 也能写文件
with 是上下文管理器:块结束时(包括中途抛异常时)自动调用 f.close()。
永远用 with,不要手动 open / close。
漏关文件不只是「少释放一个句柄」这么轻。写入是带缓冲的——f.write(...) 只是把数据
放进内存缓冲区,攒够一批才真正落盘,close() 负责把最后没攒满的那一批刷出去。
中途抛了异常又没 close,最后写的内容就可能根本没进文件,
表现为「程序明明跑完了,输出文件却少了几行」。
常用模式:
| 模式 | 含义 |
|---|---|
"r" |
只读(默认) |
"w" |
写入,清空原内容 |
"a" |
追加 |
"rb" / "wb" |
二进制模式 |
"r+" |
读写 |
OJ 上不要用文件¶
牛客、Codeforces、LeetCode 等在线判题一律通过标准输入输出交互:
在 OJ 上写 open("input.txt") 会直接 RE(FileNotFoundError)。
只有 NOIP(全国青少年信息学奥林匹克联赛)/ NOI 系列的文件 IO 赛制要求读写指定文件名,
那时的标准写法是重定向:
import sys
sys.stdin = open("problem.in", "r")
sys.stdout = open("problem.out", "w")
# 之后 input() / print() 照常用
本地调试时这一招也很好用——把样例存进 in.txt,就不用每次手敲输入了。
16.8 例题:PIO3 多组_A+B_EOF形式¶
每行两个整数 \(a, b\ (1 \le a, b \le 10^9)\),读取至文件末尾为止, 每行输出 \(a + b\)。 题面见 PIO3 原题(牛客)。
「读到 EOF」是异常处理在竞赛里的唯一高频场景。三种写法,逐个分析。
写法一:try / except EOFError(教科书写法,不推荐)¶
能过,但有三个问题:
- 空行会抛
ValueError而不是EOFError。 末尾多一个空行时,"".split()得到[],解包失败 →ValueError→ 未捕获 → RE。 要写对得捕获except (EOFError, ValueError):,但这样又会把真正的数据错误一起吞掉。 input()是最慢的读入方式,比sys.stdin慢 5–10 倍。- 异常是控制流——虽然这里只抛一次,开销可以忽略,但习惯不好。
写法二:迭代 sys.stdin(推荐)¶
import sys
def main():
out = []
for line in sys.stdin: # 迭代到 EOF 自然结束,不需要异常
if not line.split(): # 跳过空行
continue
a, b = map(int, line.split())
out.append(a + b)
sys.stdout.write("\n".join(map(str, out)) + "\n")
main()
sys.stdin 是一个可迭代的文件对象(见
15-面向对象与迭代器生成器),
读到 EOF 时迭代自然停止——用不着异常。
这就是 16.6 的原则在起作用:能用正常控制流表达的,不要用异常。
写法三:token 流(数据量大时最快)¶
import sys
def main():
# split() 不带参数会把所有空白(空格、换行)都当分隔符,
# 于是整份输入变成一串 token,行的概念消失,空行也自动没了
data = sys.stdin.buffer.read().split()
out = []
# 每组数据占 2 个 token,所以步长取 2;i 是 a 的位置,i+1 是同组的 b
for i in range(0, len(data), 2):
out.append(int(data[i]) + int(data[i + 1]))
sys.stdout.write("\n".join(map(str, out)) + "\n")
main()
一次性把所有 token 读进来,换行位置完全无关, 所以空行、行尾空格、Windows 换行全部自动免疫。
| 写法 | 速度 | 空行容错 | 推荐度 |
|---|---|---|---|
try/except EOFError |
1× | ❌ 会 RE | 不推荐 |
for line in sys.stdin |
约 5–8× | ✅ 手动 continue |
通用首选 |
buffer.read().split() |
约 20–50× | ✅ 天然免疫 | 大数据量首选 |
本章与输入输出章的分工:这里讲的是「为什么不用异常做 EOF 判断」, 各种输入形态的完整套路见 20-输入输出处理。
完整题解:solutions/PIO3.py
16.9 本章速查¶
| 要点 | 结论 |
|---|---|
| 导入 | 永远显式导入;绝不用 from math import *(会覆盖内置 pow) |
| 热点函数 | from heapq import heappush 比 heapq.heappush 快约 10% |
| 模块执行 | import 会执行顶层代码,且只执行一次 |
__name__ |
直接运行是 "__main__",被导入是模块名 |
| 包 | OJ 单文件提交,用不到 |
try/except/else/finally |
else = 无异常时;finally = 一定执行(含 return 前) |
裸 except: |
别写,至少 except Exception: |
| 捕获多种 | except (IndexError, KeyError): 或用父类 except LookupError: |
| RE = 未捕获的异常 | 从异常名反推 bug,见 16.4 的对照表 |
IndexError |
越界 / 0 与 1 下标混用 / 空列表取首尾 |
RecursionError |
深度超 1000,sys.setrecursionlimit + 大栈线程 |
ValueError |
空行解包 / int("") |
TypeError |
用了 / 当下标;给 sum/max 等内置名赋过值 |
UnboundLocalError |
函数里写全局变量忘了 global |
| 异常性能 | 进 try 几乎免费,抛一次很贵,别当控制流用 |
| 计数场景 | 用 defaultdict/Counter,不要 try KeyError |
assert |
本地对拍用,提交前删掉 |
| 文件读写 | 永远用 with open(...) as f |
| OJ 的输入输出 | 一律 stdin/stdout,写 open("input.txt") 直接 RE |
| 文件赛制 | sys.stdin = open("x.in") 重定向,本地调试也好用 |
| EOF 多组数据 | for line in sys.stdin,不要 try/except EOFError |