AI 技术博客
返回首页
Python 基础 21 分钟阅读

文件异常与防御式读写:OSError 家族实战

Python异常处理文件操作防御式编程

引言

这是第七章「文件与异常处理」的第 5 篇。第四章第 8 篇《异常处理:try/except/finally 与自定义异常》已经把异常语法讲透了:try/except/else/finally 的执行顺序、raise 与自定义异常、EAFP 与 LBYL 两种风格的取舍。本篇不再重复语法,而是把这些武器真正用到文件读写场景里——因为文件操作恰恰是异常最密集的地方:文件可能不存在、可能没权限、可能被别的程序占用、可能写到一半磁盘满了。

本系列第 1-4 篇分别解决了打开、读写、编码、路径抽象的问题,本篇要回答的核心问题是:当这些操作失败时,程序收到什么? 答案是 OSError 家族。我们将系统认识它的成员(FileNotFoundError、PermissionError、IsADirectoryError、NotADirectoryError、FileExistsError),掌握”先检查再操作”(LBYL)与”先尝试再兜底”(EAFP)在文件场景中的取舍,并用本机 Windows + Python 3.12 实测演示真实失败场景——尤其是 Windows 特有的”文件被占用”问题。前置知识:第四章第 8 篇的异常语法、第 1-3 篇的 open/读写/编码、第 4 篇的 pathlib。

概念与原理

文件操作为什么会失败:失败的三类根源

把”文件打不开”归因于”文件不存在”是以偏概全的。系统视角下,一次 open() 调用失败只有三类原因,它们的失败时机不同:

第一类是路径本身无效——目标路径不存在,或者路径的某个中间成分不是目录(比如 C:\data\log.txt\child.txt,把文件当目录用了)。操作系统在解析路径时就会返回错误,Python 将其映射为 FileNotFoundError 或 NotADirectoryError。第二类是权限不足——文件存在,但当前用户没有对应的访问权限,或者(Windows 特有的情况)文件正被其他进程以独占方式打开。这类失败映射为 PermissionError。第三类是模式冲突——你想创建文件但同名文件已经存在(使用 x 模式或 os.open 的排他标志时),或者想打开目录当普通文件。这类映射为 FileExistsError 或 IsADirectoryError。

理解失败根源的价值在于:不同异常意味着不同的恢复策略。文件不存在时,程序可以走”新建文件”或”使用默认配置”分支;权限不足时,重试往往无效,应当提示用户而不是循环重试;文件被占用时,合理的策略可能是稍后再试。用同一个 except Exception 把三类情况混为一谈,是防御式编程的大忌。

OSError 家族:统一基类与 errno 属性

第四章第 8 篇的异常层次图里已经提到 OSError。文件相关的所有异常都挂在它名下,它们是同一个家族:

# 用 issubclass 验证 OSError 家族的真实继承关系
for exc in (FileNotFoundError, PermissionError, IsADirectoryError,
            NotADirectoryError, FileExistsError, OSError):
    print(f'{exc.__name__:20s} 直接基类: {exc.__mro__[1].__name__}')
print('----')
print('FileNotFoundError 是 OSError 子类:', issubclass(FileNotFoundError, OSError))
print('PermissionError 是 OSError 子类:', issubclass(PermissionError, OSError))
FileNotFoundError    直接基类: OSError
PermissionError      直接基类: OSError
IsADirectoryError    直接基类: OSError
NotADirectoryError   直接基类: OSError
FileExistsError      直接基类: OSError
OSError              直接基类: Exception
----
FileNotFoundError 是 OSError 子类: True
PermissionError 是 OSError 子类: True

这个层次带来两条实用结论。其一,捕获 OSError 就能兜住所有文件操作失败,这是比 except Exception 更精确的写法——它不会误伤 ValueError、TypeError 等与文件无关的异常。其二,每个 OSError 实例都携带 errno(整数错误码)、strerror(系统错误描述)和 filename(出问题的文件名)属性,日志里打印 e.errno 与 e.strerror 比只打印异常对象更利于排查。Windows 上还有额外的 winerror 属性,保存 Windows 原生错误码。

同一操作,不同平台,不同异常(以实测为准)

异常映射并不是全平台一致的,这是初学文件异常最容易踩的坑。以”用 open() 打开一个目录”为例:在 Linux/macOS 上会抛 IsADirectoryError,而 Windows 上直接表现为 PermissionError。再以”路径中间夹着文件”为例:POSIX 系统解析出 NotADirectoryError,Windows 上则报 FileNotFoundError。因此,编写跨平台代码时不要依赖某一个具体子类,最稳妥的兜底是捕获 OSError;而针对特定平台的优化(比如 Windows 上等待文件被释放后再重试),则可以用 winerror 精确判断。下面两节的所有输出均来自本机 Windows + Python 3.12 实测,与你自己的平台结果不同是正常的,但判断”该用哪个异常”的原则不变。

操作与实现

实测 open 失败的三种真实场景

在临时目录里逐一构造三种失败场景,观察实际抛出的异常类型:

import os, tempfile

with tempfile.TemporaryDirectory() as td:
    # 场景 1:文件不存在
    try:
        open(os.path.join(td, 'nope.txt'), encoding='utf-8')
    except FileNotFoundError as e:
        print('场景1 不存在:', type(e).__name__, '| errno =', e.errno)

    # 场景 2:把目录当普通文件打开(Windows 上表现为 PermissionError)
    try:
        open(td, encoding='utf-8')
    except PermissionError as e:
        print('场景2 打开目录:', type(e).__name__, '| errno =', e.errno)

    # 场景 3:路径中间夹着文件(Windows 上表现为 FileNotFoundError)
    f = os.path.join(td, 'a.txt')
    open(f, 'w').close()
    try:
        open(os.path.join(f, 'child.txt'), 'w')
    except FileNotFoundError as e:
        print('场景3 文件当目录:', type(e).__name__, '| errno =', e.errno)

    # 顺便看 errno 与 strerror
    try:
        open(os.path.join(td, 'nope.txt'), encoding='utf-8')
    except FileNotFoundError as e:
        print('errno =', e.errno, '| strerror =', e.strerror)
场景1 不存在: FileNotFoundError | errno = 2
场景2 打开目录: PermissionError | errno = 13
场景3 文件当目录: FileNotFoundError | errno = 2
errno = 2 | strerror = No such file or directory

三个场景的差异值得记住:errno = 2 表示”找不到”(Windows 代码 ERROR_FILE_NOT_FOUND),errno = 13 表示”权限被拒”(Windows 代码 ERROR_ACCESS_DENIED)。场景 2 在 POSIX 上是 IsADirectoryError(errno = 21),这是平台差异的典型例子。注意场景 3 里 a.txt 存在、child.txt 不存在,Windows 直接报”找不到”,因为文件系统的路径解析在遇到非目录成分时就停下来了。

Windows 实测:文件被占用时的 PermissionError

Windows 与 POSIX 一个显著的行为差异是文件占用:一个文件被进程以写模式打开后,其他进程(甚至同一进程的其他句柄)通常无法删除或重命名它,直到句柄释放。Windows 的默认共享模式允许同一进程内重复打开(前面的实测确认了 Python 内部再次 open 写同一文件不会立即报错),但删除仍会被拒绝。这是日常运维脚本最常见的坑——日志轮转、临时文件清理经常因此失败:

import os, tempfile

with tempfile.TemporaryDirectory() as td:
    path = os.path.join(td, 'report.txt')
    fh = open(path, 'w', encoding='utf-8')   # 先以写模式打开,句柄不释放
    fh.write('今日数据')
    fh.flush()                                # 确保内容已进入操作系统

    # Windows:文件被占用(有打开的写句柄)时删除会失败
    try:
        os.remove(path)
    except PermissionError as e:
        print('删除被占用文件:', type(e).__name__, '| winerror =', e.winerror)

    fh.close()                                # 释放句柄
    os.remove(path)                           # 现在可以删除了
    print('句柄释放后删除成功:', not os.path.exists(path))
删除被占用文件: PermissionError | winerror = 32
句柄释放后删除成功: True

winerror = 32 就是 Windows 的 ERROR_SHARING_VIOLATION(共享冲突)。注意到关键点是 with 语句之外的手动句柄带来了风险:写文件时没有及时 close(),后续删除/重命名就失败。这正是第 1 篇反复强调”用 with 管理文件”的深层原因——with 块结束即释放句柄,整个文件的”占用窗口”被压缩到最小。若在真实日志场景中遇到 PermissionError(winerror 32),正确策略是等待片刻后重试(文件可能正被另一进程短暂读写),或者明确提示用户关闭占用程序。

防御式读写:先检查 vs 直接尝试(EAFP 对决)

第四章第 8 篇讲过 EAFP 与 LBYL 两种风格的取舍,现在看它们在文件场景的真实形态。以”读取配置,缺失则返回空字典”为例:

import json, os, tempfile

def load_config_lbyl(path):
    if os.path.exists(path):                  # LBYL:先检查,再操作
        with open(path, encoding='utf-8') as f:
            return json.load(f)
    return {}

def load_config_eafp(path):
    try:                                      # EAFP:先尝试,失败再兜底
        with open(path, encoding='utf-8') as f:
            return json.load(f)
    except FileNotFoundError:
        return {}

with tempfile.TemporaryDirectory() as td:
    cfg = os.path.join(td, 'config.json')
    with open(cfg, 'w', encoding='utf-8') as f:
        json.dump({'theme': 'dark', 'lang': 'zh'}, f)

    print('LBYL 读已存在配置:', load_config_lbyl(cfg))
    print('EAFP 读已存在配置:', load_config_eafp(cfg))
    missing = os.path.join(td, 'no-such.json')
    print('EAFP 读不存在配置:', load_config_eafp(missing))
    print('LBYL 读不存在配置:', load_config_lbyl(missing))
LBYL 读已存在配置: {'theme': 'dark', 'lang': 'zh'}
EAFP 读已存在配置: {'theme': 'dark', 'lang': 'zh'}
EAFP 读不存在配置: {}
LBYL 读不存在配置: {}

两者结果一致,但工程上推荐 EAFP,原因有二。其一,检查与操作之间存在竞态窗口:os.path.exists() 返回 True 到 open() 执行之间,文件可能已被删除或权限被改,LBYL 的检查结果立刻过期;EAFP 直接让操作系统裁决,错误发生时异常对象自带 filename 属性,信息更完整。其二,EAFP 的失败路径自动覆盖”检查发现不了的问题”:比如路径存在但是个目录,LBYL 的 exists() 返回 True 然后 open 照样失败(Windows 上抛 PermissionError),而 EAFP 只需把 except 从句换成 except OSError 就能一并兜住。注意 EAFP 的精确性要求:只捕获你预期的异常,别用裸 except: 吞掉 PermissionError、JSONDecodeError 等本该暴露的问题。

部分写入失败的稳健处理:临时文件 + 原子替换

最后一个实战问题:写入是”边写边落盘”的过程,如果写到一半程序崩溃、磁盘满或断电,目标文件会留下半截内容——对于配置文件、报表、数据库导出这类必须保持完整的数据,这是不可接受的。稳健方案是”先写临时文件,成功后原子替换”:

import json, os, tempfile

def safe_write_json(path, data):
    """先写临时文件,成功后再原子替换,避免部分写入留下半截文件"""
    tmp = path + '.tmp'
    with open(tmp, 'w', encoding='utf-8') as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
        f.flush()
        os.fsync(f.fileno())          # 把缓冲真正刷到磁盘
    os.replace(tmp, path)             # 原子替换;Windows 上目标已存在也能覆盖

with tempfile.TemporaryDirectory() as td:
    target = os.path.join(td, 'stats.json')
    safe_write_json(target, {'views': 1024, 'page': '首页'})
    with open(target, encoding='utf-8') as f:
        print('读回内容:', json.load(f))
    print('临时文件已清理:', not os.path.exists(target + '.tmp'))

    # 重复写入:覆盖已有文件也安全
    safe_write_json(target, {'views': 2048, 'page': '首页'})
    with open(target, encoding='utf-8') as f:
        print('覆盖写入后:', json.load(f))
读回内容: {'views': 1024, 'page': '首页'}
临时文件已清理: True
覆盖写入后: {'views': 2048, 'page': '首页'}

这个模式的三步各有作用:f.flush() 把 Python 缓冲区的数据交给操作系统,os.fsync(f.fileno()) 再强制操作系统把数据真正写到磁盘(防止系统崩溃时丢数据),最后 os.replace() 完成原子替换——在 Windows 上 replace 是”目标存在也能直接覆盖”的语义(第 6 篇会对比 os.rename 在 Windows 上目标存在时反而抛 FileExistsError 的行为)。其收益是:无论第几步失败,要么目标文件保持旧版本完好,要么只残留一个可清理的 .tmp 文件,永远不会出现半截的目标文件。第 8 篇《实战:配置文件的读取、校验与默认值》会把这套模式用到配置持久化上。

易错点与陷阱

陷阱一:用裸 except: 或 except Exception 吞掉文件错误

第四章第 8 篇已经批评过裸 except:,在文件场景它的危害更具体:KeyboardInterrupt 会在用户按 Ctrl+C 时被吞掉,程序假装无事发生;而 except Exception 则会把 PermissionError 和 FileNotFoundError 用同一套逻辑处理——文件不存在可以优雅降级,权限不足却需要用户干预,混在一起会导致”静默地反复失败”。正确姿势是分层捕获:需要的具体子类在前,OSError 兜底在后,最后再决定是否 raise 给上层。

陷阱二:只按语法书记忆,忽略平台差异

“打开目录一定抛 IsADirectoryError”——这句话在 Windows 上是错的(实测是 PermissionError)。同理”把文件当目录用一定抛 NotADirectoryError”在 Windows 上也是 FileNotFoundError。处理跨平台问题时,先在你的目标平台上实测,再决定捕获哪个异常;不确定时统一写 except OSError。项目里如果只面向 Windows(比如运维脚本),直接探测 winerror 数值(32 表示共享冲突)比依赖异常名更精确。

陷阱三:文件句柄不及时释放,导致删除/重命名被拒

fh = open(...) 后忘了 close(),或者用异常做控制流导致 close() 没执行,Windows 上随后删除或重命名该文件必然遇到 PermissionError(winerror 32);在 POSIX 上则不会报错,可能掩盖延迟释放的问题,直到并发量上来才爆发。解决办法是让 with 管住生命周期,或至少用 try/finally 保证 close()。这也解释了为什么”写日志”这类高频小文件操作都该用 with open(...) 而不是手写开关。

陷阱四:写入时不考虑”写一半”的后果

直接 open(path, 'w') 写入配置/报表,一旦中途失败,旧文件被清空、新内容不完整,数据不可恢复。稳健的做法就是本篇的临时文件 + os.replace 模式;更进一步,先 flush 再 fsync,把”应用层写完”和”数据真正落盘”区分开。判断是否需要这一层防护的标尺是:这份文件损坏的代价有多大——缓存类文件无所谓,配置和数据文件必须防护。

小结

文件操作的所有失败都汇聚到 OSError 这一家族:FileNotFoundError(不存在)、PermissionError(权限或占用)、IsADirectoryError/NotADirectoryError(类型错配)、FileExistsError(已存在冲突),它们都携带 errno/strerror/filename 属性,Windows 上还有 winerror。防御式读写以 EAFP 为默认风格——直接在 try 里操作、按预期捕获具体异常、OSError 兜底;对”文件被占用”这类 Windows 高频问题,用 with 压缩占用窗口、必要时捕获 PermissionError 后重试;对不能损坏的数据,用临时文件加 os.replace 的原子写入模式。掌握了这些,第七章第 6 篇《os 模块:目录操作、环境变量与系统交互》里目录创建、重命名、遍历的异常处理就有同样的思路可供迁移。

练习与思考题

  1. 写一个 safe_read_json(path) -> dict,用 EAFP 风格捕获 OSError 并区分三种情况打印不同提示:不存在(返回 {})、权限不足(返回 {} 并打印警告)、其他错误(重新抛出)。在自己的临时目录里分别构造三种场景验证。
  2. 用 tempfile.TemporaryDirectory 复现”文件被占用”:打开文件写入但不关闭,尝试删除,观察 Windows 上的异常类型与 winerror;随后释放句柄再删除,确认成功。如果在 Linux 上运行同样的代码,现象有何不同?
  3. 比较 load_config_lbyl 与 load_config_eafp:把 except FileNotFoundError 改成 except OSError,分别用”路径是目录""路径无权限”两个用例测试,说明 EAFP 版本的覆盖优势。
  4. 思考题:os.replace 被称为”原子替换”,为什么它能保证”要么旧文件完好、要么新文件完好”?如果不用临时文件模式而直接 open(path, 'w'),进程在写入中间被 kill,目标文件的状态是什么?