跳转至

第 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。 同理它还会覆盖 egammalog 等常见变量名。永远显式导入。

\(10^6\) 级别的循环里,from heapq import heappushheapq.heappush 快约 10%—— 因为省掉了每次的模块属性查找。热点代码值得这么写。

import 的机制

import mymod

Python 做四件事:

  1. sys.path 的顺序查找 mymod.py(当前目录 → PYTHONPATH → 标准库 → site-packages)。
  2. 编译成字节码,缓存在 __pycache__/mymod.cpython-39.pyc
  3. 执行整个模块文件(顶层代码全部跑一遍)。
  4. 把模块对象绑定到名字 mymod

两个重要推论:

  • 模块只会被执行一次。首次导入后,模块对象就被登记进 sys.modules 这个字典; 之后每次 import 都是先查这张表,命中就直接返回同一个对象。 所以在多个文件里重复 import 不会有性能损失, 也意味着模块顶层的全局状态是全程序共享的一份
  • import 会执行顶层代码。所以模块里的可执行逻辑要放进 if __name__ == "__main__": 里保护起来,否则被别人 import 时会意外运行。
if __name__ == "__main__":
    main()

__name__ 在「直接运行」时是 "__main__",在「被 import」时是模块名。

竞赛里写不写 if __name__ 写不写都能过。 但把逻辑放进 main() 函数必须做——局部变量比全局变量快 20%–30%(见第 1 章)。

__init__.py 的目录就是包(Python 3.3+ 起 __init__.py 也可以省略):

project/
    main.py
    utils/
        __init__.py
        io.py
        algo.py
from utils.io import fast_read
from utils import algo
from . import algo             # 相对导入,只能在包内部用

OJ 只接受单文件提交,所以竞赛中永远用不到包。 本地整理模板库时有用:把常用模板放进一个 templates/ 包,写题时复制粘贴。

查看模块内容

import math
print(dir(math))               # 列出所有名字
print(math.__file__)           # 模块文件路径(内建模块没有这个属性)
help(math.gcd)                 # 查看文档

本地调试时很方便,OJ 上没意义。


16.2 异常:语法错误 vs 运行时异常

if x > 0                       # ❌ SyntaxError:少冒号,程序根本不会启动
    print(x)

a = [1, 2]
print(a[5])                    # ❌ IndexError:语法没问题,运行到这里才崩
类别 何时发现 OJ 反馈
语法错误(SyntaxErrorIndentationError 编译期,整个文件都不会执行 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
无异常 全部执行 跳过 执行 执行
有异常且被捕获 执行到出错处 执行 跳过 执行
有异常但没匹配上 执行到出错处 跳过 跳过 执行,然后异常继续向上抛
tryreturn 仍然执行(在 return 生效前)

else 的意义:把「不会出异常的代码」挪出 try, 避免它抛出的异常被同一个 except 误捕获。

try:
    v = d[k]
except KeyError:
    v = 0
else:
    v = process(v)             # 如果 process 也抛 KeyError,不会被上面的 except 吃掉

不要写裸 except:。它会连 KeyboardInterruptSystemExit 都捕获, 也会把拼写错误(NameError)静默吞掉,让 bug 变得无法定位。 至少写 except Exception:


16.4 竞赛里 RE 的成因对照表

OJ 上显示 Runtime Error 时,本质就是「有一个异常没被捕获」。 下表按竞赛出现频率排序,这是本章最该记住的内容

异常 典型触发 竞赛里的真实原因
IndexError a[i]i 越界 数组开小了;下标 0/1 混用;nlen(a) 不一致;空列表取 a[0]a[-1]
RecursionError 递归太深 DFS 深度超 1000(默认上限);忘了写递归出口
ZeroDivisionError x / 0x // 0x % 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: 能同时捕获 IndexErrorKeyErrorexcept ArithmeticError: 能捕获除零。

本地调试:让异常暴露完整信息

import traceback

try:
    main()
except Exception:
    traceback.print_exc()      # 打印完整调用栈

Python 的默认报错信息已经包含文件名、行号、调用链,比 C++ 的段错误友好得多。 看到 RE 不要慌,本地跑一遍样例,报错行号直接指出出错位置。


16.5 raiseassert

raise ValueError("n 必须为正")          # 主动抛出
raise                                   # 在 except 块里重新抛出当前异常

assert n > 0, "n 必须为正"              # 断言,失败时抛 AssertionError

自定义异常:

class MyError(Exception):
    pass

竞赛里 raiseassert 的正确用法是「本地自测」

assert len(a) == n, "读入的元素个数不对"

写在本地跑对拍时能快速定位问题。提交前建议删掉—— 断言会带来微小开销,而且一旦触发就是 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 等在线判题一律通过标准输入输出交互

import sys

data = sys.stdin.buffer.read().split()     # 读
sys.stdout.write(result)                   # 写

在 OJ 上写 open("input.txt") 会直接 REFileNotFoundError)。 只有 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(教科书写法,不推荐)

while True:
    try:
        a, b = map(int, input().split())
    except EOFError:
        break
    print(a + b)

能过,但有三个问题

  1. 空行会抛 ValueError 而不是 EOFError。 末尾多一个空行时,"".split() 得到 [],解包失败 → ValueError → 未捕获 → RE。 要写对得捕获 except (EOFError, ValueError):,但这样又会把真正的数据错误一起吞掉。
  2. input() 是最慢的读入方式,比 sys.stdin 慢 5–10 倍。
  3. 异常是控制流——虽然这里只抛一次,开销可以忽略,但习惯不好。

写法二:迭代 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 ❌ 会 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 heappushheapq.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