Python 文件读写与异常处理实战:基于 Python-100-Days 的数据持久化与健壮性编程指南

原创2026-09-29 23:58:35997 阅读
文章标签:文档教程

Python 文件读写与异常处理实战:基于 Python-100-Days 的数据持久化与健壮性编程指南

本篇文章聚焦于《Python-100-Days》Day21-30 阶段的核心基础能力——文件读写与异常处理,涵盖 open 函数的操作模式与编码参数、文本/二进制文件的读写方法、以 try/except/else/finally/raise 为核心的异常机制、with 上下文管理器语法,并结合仓库内真实源码(如 Day31-35/code/example06.py、Day31-35/code/example07.py、Day36-45/code/contact/main.py)印证其在实际工程中的用法。读完本文,你将掌握在 Python 中实现数据持久化的完整套路,并写出具备容错能力、不会因一个文件不存在就整体崩溃的健壮代码。

Python open 函数文件操作模式选择示意图

为什么需要文件读写:数据持久化的第一课

实际开发中常常会遇到对数据进行持久化的场景。所谓持久化,是指将数据从无法长久保存数据的存储介质(通常是内存)转移到可以长久保存数据的存储介质(通常是硬盘)中。实现数据持久化最直接简单的方式,就是通过文件系统将数据保存到文件中。

计算机的文件系统是一种存储和组织计算机数据的方法,它使得对数据的访问和查找变得容易。文件系统使用文件和树形目录的抽象逻辑概念,代替了硬盘、光盘、闪存等物理设备的数据块概念:用户使用文件系统保存数据时,不必关心数据实际保存在硬盘的哪个数据块上,只需要记住这个文件的路径和文件名;在写入新数据之前,也不必关心硬盘上哪个数据块没有被使用——存储空间的管理(分配和释放)由文件系统自动完成。

正是基于这个抽象,Python 开发者可以用几行代码完成"把内存中的结果落盘"、"从磁盘恢复数据"的操作,这也是后续课程中 CSV/Excel 读写、对象序列化(22.对象的序列化和反序列化.md)等一切数据持久化操作的基础。

打开与关闭文件:open 函数与操作模式

在 Python 中要实现文件操作非常简单,使用内置的 open 函数即可。通过参数可以指定文件名、操作模式和字符编码等信息,随后即可对文件进行读写操作。操作模式决定了要打开什么样的文件(字符文件或二进制文件)以及做什么样的操作(读、写或追加),具体如下表:

操作模式 具体含义
'r' 读取(默认)
'w' 写入(会先截断之前的内容)
'x' 写入,如果文件已经存在会产生异常
'a' 追加,将内容写入到已有文件的末尾
'b' 二进制模式
't' 文本模式(默认)
'+' 更新(既可以读又可以写)

这些基础模式可以组合使用,例如 'rb'(二进制只读)、'wb'(二进制写入)、'w+'(读写并截断)、'r+'(从文件开头读写)、'a+'(从文件末尾读写)。上图展示了如何根据程序需要来设置 open 函数的操作模式:仅读取时用 r;仅写入时通过"是否截断"区分 w 与 a;同时读写时再通过"截断与否"和"初始位置(文件开头/末尾)"区分 w+、r+、a+。

注意:在使用 open 函数时,如果打开的是文本文件,可以通过 encoding 参数指定读写文件使用的字符编码。若对字符编码和字符集概念不熟悉,建议先理解 UTF-8、GBK 等常用编码的差异——如果不能保证保存文件时使用的编码与 encoding 参数指定的一致,就可能因无法解码字符而导致读取失败。

使用 open 函数成功打开文件后会返回一个文件对象,通过该对象即可实现对文件的读写操作;如果打开失败,open 函数会引发异常(详见下文异常机制)。操作结束后,应调用文件对象的 close 方法关闭文件,释放系统资源。

读写文本文件

用 open 打开文本文件时,操作模式设为 'r'(不指定时默认也是 'r');可通过 encoding 参数指定字符编码,不指定时默认值为 None,此时使用操作系统默认编码。下面的例子演示了如何读取一个纯文本文件——纯文本一般指只有字符原生编码构成的文件,不包含字符样式的控制元素,能够被最简单的文本编辑器直接读取:

file = open('致橡树.txt', 'r', encoding='utf-8')
print(file.read())
file.close()

说明:示例中的 致橡树.txt 内容是舒婷老师于 1977 年 3 月创作的爱情诗《致橡树》——"我如果爱你 / 绝不像攀援的凌霄花,借你的高枝炫耀自己……不仅爱你伟岸的身躯,也爱你坚持的位置,足下的土地。"作为测试用文本,它恰好能验证中文在 UTF-8 编码下的读写是否正确。

除了一次性调用 read 方法读取全部内容,还可以用 for-in 循环逐行读取,或者用 readlines 方法将文件按行读取到一个列表容器中:

file = open('致橡树.txt', 'r', encoding='utf-8')
for line in file:
    print(line, end='')
file.close()

file = open('致橡树.txt', 'r', encoding='utf-8')
lines = file.readlines()
for line in lines:
    print(line, end='')
file.close()

向文件写入内容时,使用 w 或 a 作为操作模式:前者会截断之前的文本内容再写入新内容,后者是在原来内容的尾部追加新内容:

file = open('致橡树.txt', 'a', encoding='utf-8')
file.write('\n标题:《致橡树》')
file.write('\n作者:舒婷')
file.write('\n时间:1977年3月')
file.close()

仓库源码印证:追加模式 'a' 是工程中写日志的常用姿势。在 Day31-35/code/example09.py 中,装饰器示例将函数耗时记录追加写入日志文件:

def output_to_file(fname, duration):
    with open('log.txt', 'a') as file_stream:
        file_stream.write('%s: %.3f秒\n' % (fname, duration))

多次调用装饰器时,log.txt 中的多条记录不会被互相覆盖,这正是 a 模式"追加"语义的实际价值。

异常处理机制:让程序面对异常状况不崩溃

请注意上面的代码——如果 open 函数指定的文件并不存在或无法打开,将引发异常导致程序崩溃。为了让代码具有健壮性和容错性,可以使用 Python 的异常机制对可能在运行时发生状况的代码进行适当处理。Python 中和异常相关的关键字有五个:try、except、else、finally 和 raise。先看下面的代码:

file = None
try:
    file = open('致橡树.txt', 'r', encoding='utf-8')
    print(file.read())
except FileNotFoundError:
    print('无法打开指定的文件!')
except LookupError:
    print('指定了未知的编码!')
except UnicodeDecodeError:
    print('读取文件时解码错误!')
finally:
    if file:
        file.close()

这段代码演示了异常机制的核心用法:

  • try 块:放置运行时可能出现状况的代码;
  • except 块:一个或多个,按书写顺序依次匹配指定的异常类型。上面的例子中,文件找不到引发 FileNotFoundError,指定未知编码引发 LookupError,无法按指定编码解码文件引发 UnicodeDecodeError,因此用了三个 except 分别处理三种异常状况;
  • else 块:try 中的代码没有出现异常时执行;且 else 中的代码不会再进行异常捕获,若这里遇到异常,程序会因异常而终止并报告异常信息;
  • finally 块:不论程序正常还是异常都会执行——甚至是调用了 sys 模块的 exit 函数终止 Python 程序,finally 块中的代码仍会被执行(因为 exit 函数的本质是引发了 SystemExit 异常)。因此 finally 被称为"总是执行代码块",最适合用来做释放外部资源的操作,例如关闭打开的文件。

Python 内置异常继承结构

Python 中内置了大量异常类型,其继承结构如下(节选关键分支):

BaseException
 +-- SystemExit
 +-- KeyboardInterrupt
 +-- GeneratorExit
 +-- Exception
      +-- StopIteration
      +-- StopAsyncIteration
      +-- ArithmeticError
      |    +-- FloatingPointError
      |    +-- OverflowError
      |    +-- ZeroDivisionError
      +-- AssertionError
      +-- AttributeError
      +-- BufferError
      +-- EOFError
      +-- ImportError
      |    +-- ModuleNotFoundError
      +-- LookupError
      |    +-- IndexError
      |    +-- KeyError
      +-- MemoryError
      +-- NameError
      |    +-- UnboundLocalError
      +-- OSError
      |    +-- BlockingIOError
      |    +-- ChildProcessError
      |    +-- ConnectionError
      |    |    +-- BrokenPipeError
      |    |    +-- ConnectionAbortedError
      |    |    +-- ConnectionRefusedError
      |    |    +-- ConnectionResetError
      |    +-- FileExistsError
      |    +-- FileNotFoundError
      |    +-- InterruptedError
      |    +-- IsADirectoryError
      |    +-- NotADirectoryError
      |    +-- PermissionError
      |    +-- ProcessLookupError
      |    +-- TimeoutError
      +-- ReferenceError
      +-- RuntimeError
      |    +-- NotImplementedError
      |    +-- RecursionError
      +-- SyntaxError
      |    +-- IndentationError
      |         +-- TabError
      +-- SystemError
      +-- TypeError
      +-- ValueError
      |    +-- UnicodeError
      |         +-- UnicodeDecodeError
      |         +-- UnicodeEncodeError
      |         +-- UnicodeTranslateError
      +-- Warning
           +-- DeprecationWarning
           +-- PendingDeprecationWarning
           +-- RuntimeWarning
           +-- SyntaxWarning
           +-- UserWarning
           +-- FutureWarning
           +-- ImportWarning
           +-- UnicodeWarning
           +-- BytesWarning
           +-- ResourceWarning

从这个结构可以看出:

  • Python 中所有异常都是 BaseException 的子类型,它有四个直接子类:SystemExit(解释器请求退出)、KeyboardInterrupt(用户按 Ctrl+c 中断程序)、GeneratorExit(生成器发生异常通知退出)和 Exception;
  • Exception 是常规异常类型的父类型,绝大多数异常都直接或间接继承自它。文件读写相关错误(FileNotFoundError、PermissionError、UnicodeDecodeError 等)都在 Exception 之下;
  • 如果内置异常类型不能满足需要,可以自定义异常类型,自定义异常也应直接或间接继承自 Exception,并按需重写或添加方法。

自定义异常与 raise 抛出异常

在 Python 中,可以使用 raise 关键字引发(抛出)异常对象,调用者通过 try...except... 结构捕获并处理异常。例如在函数中,当执行条件不满足时,通过抛出异常告知调用者问题所在,调用者通过捕获处理使代码从异常中恢复:

class InputError(ValueError):
    """自定义异常类型"""
    pass


def fac(num):
    """求阶乘"""
    if num < 0:
        raise InputError('只能计算非负整数的阶乘')
    if num in (0, 1):
        return 1
    return num * fac(num - 1)

调用 fac 函数,通过 try...except... 捕获输入错误异常并打印异常对象(显示异常信息),输入正确则计算阶乘并结束程序:

flag = True
while flag:
    num = int(input('n = '))
    try:
        print(f'{num}! = {fac(num)}')
        flag = False
    except InputError as err:
        print(err)

仓库源码印证:真实工程中,异常处理同样遵循"精确捕获、就地处理"的模式。在 Day36-45/code/contact/main.py(一个基于 PyMySQL 的联系人管理系统)中,数据库操作统一捕获 pymysql.MySQLError 并给出用户可读的提示,而不是让程序直接崩溃:

def add_new_contacter(con):
    name, tel, email = input_contacter_info()
    try:
        with con.cursor() as cursor:
            if cursor.execute(INSERT_CONTACTER,
                              (name, tel, email)) == 1:
                print('添加联系人成功!')
    except pymysql.MySQLError as err:
        print(err)
        print('添加联系人失败!')

同时,Day31-35/code/example03.py 展示了用 except KeyError 精确捕获字典缓存未命中场景的写法——动态规划求斐波拉切数时,先查缓存字典,未命中则捕获 KeyError 并计算结果回填缓存,这正是"异常只用于异常状况、不用于正常控制流"的正面示范。

with 上下文管理器语法

对于 open 函数返回的文件对象,还可以使用 with 上下文管理器语法,在文件操作完成后自动执行文件对象的 close 方法,让代码更简单优雅,无需再写 finally 块来手动释放资源。

需要提醒的是,并不是所有对象都能放进 with 语法中——只有符合上下文管理器协议(实现了 __enter__ 和 __exit__ 魔术方法)的对象才可以使用;Python 标准库的 contextlib 模块也提供了对 with 语法的支持(例如用 contextmanager 装饰器把生成器函数快速变成上下文管理器)。

用 with 改写后的文件读取代码如下:

try:
    with open('致橡树.txt', 'r', encoding='utf-8') as file:
        print(file.read())
except FileNotFoundError:
    print('无法打开指定的文件!')
except LookupError:
    print('指定了未知的编码!')
except UnicodeDecodeError:
    print('读取文件时解码错误!')

仓库源码印证:with 语法在本仓库中被广泛使用。除前面提到的 example09.py 外,Day31-35/code/example03.py 还用 contextlib.contextmanager 自定义了一个计时上下文管理器,在 finally 中确保无论 yield 后的代码是否异常都打印耗时:

@contextmanager
def timer():
    try:
        start = perf_counter()
        yield
    finally:
        end = perf_counter()
        print(f'{end - start}秒')

读写二进制文件

读写二进制文件与读写文本文件操作类似,但有两点关键差异:

  1. 操作模式:读操作用 'rb',写操作用 'wb';
  2. 数据类型:读写文本文件时,read 方法的返回值和 write 方法的参数是 str 对象(字符串);读写二进制文件时,二者是 bytes-like 对象(字节串)。

下面的代码实现了将当前路径下名为 guido.jpg 的图片文件复制到 吉多.jpg 的操作:

try:
    with open('guido.jpg', 'rb') as file1:
        data = file1.read()
    with open('吉多.jpg', 'wb') as file2:
        file2.write(data)
except FileNotFoundError:
    print('指定的文件无法打开.')
except IOError:
    print('读写文件时出现错误.')
print('程序执行结束.')

如果待复制的图片文件很大,一次将全部内容读入内存可能造成非常大的内存开销。为了减少内存占用,可以为 read 方法传入 size 参数指定每次读取的字节数,通过循环读写完成操作:

try:
    with open('guido.jpg', 'rb') as file1, open('吉多.jpg', 'wb') as file2:
        data = file1.read(512)
        while data:
            file2.write(data)
            data = file1.read()
except FileNotFoundError:
    print('指定的文件无法打开.')
except IOError:
    print('读写文件时出现错误.')
print('程序执行结束.')

提示:上面第二个示例的 while data 循环中每次只读 512 字节,本质是把"分块读取"的思想落地;当 read 到达文件末尾时会返回空字节串(b''),循环随之结束。

仓库源码印证:分块读取是处理大文件(如安装包、图片、日志)的通用手段。在 Day31-35/code/example07.py 的哈希摘要生成器 StreamHasher 中,通过 iter(lambda: file_stream.read(self.size), b'') 结合 read(size) 分块读取文件并持续更新哈希,避免一次性将大文件载入内存;同时用 file_stream.seek(0, 0) 回到文件开头,以便同一文件流用不同算法重复计算摘要:

class StreamHasher():
    """摘要生成器"""

    def __init__(self, algorithm='md5', size=4096):
        self.size = size
        cls = getattr(__import__('hashlib'), algorithm.lower())
        self.hasher = cls()

    def digest(self, file_stream):
        """生成十六进制的摘要字符串"""
        for data in iter(lambda: file_stream.read(self.size), b''):
            self.hasher.update(data)
        return self.hasher.hexdigest()

而 Day31-35/code/example06.py 则展示了二进制写入与编码转换的组合应用——从 Redis 中取出经过 BASE64 编码的图片数据,解码后以 'wb' 模式写回图片文件:

data = base64.b64decode(cli.get('guido'))
with open('guido2.jpg', 'wb') as file_stream:
    file_stream.write(data)

总结:异常机制的使用边界与最佳实践

通过读写文件,我们可以实现数据持久化:用 open 函数获得文件对象,用文件对象的 read 和 write 方法完成读写。程序在运行时可能遭遇无法预料的异常状况,可以借助 Python 的异常机制处理。回顾本课要点:

  • try/except/else/finally/raise 是异常机制的五个核心关键字;
  • try 后面的 except 语句不是必须的,finally 语句也不是必须的,但二者必须要有一个;
  • except 语句可以有一个或多个,多个 except 会按照书写的顺序依次匹配指定的异常,异常一旦处理便不再进入后续 except 语句;
  • except 语句中可以通过元组同时指定多个异常类型进行捕获(如 except (FileNotFoundError, PermissionError):);
  • except 语句后如果不指定异常类型,则默认捕获所有异常;
  • 捕获异常后可以使用 raise 再次抛出,但不建议捕获并抛出同一个异常;
  • 不建议在不清楚逻辑的情况下捕获所有异常,这可能掩盖程序中严重的问题;
  • 最后强调一点:不要使用异常机制来处理正常业务逻辑或控制程序流程——不要滥用异常,这是初学者常犯的错误。

在此基础上,把 with 上下文管理器与精确的 except 捕获相结合,配合 encoding 参数正确处理字符编码、size 参数分块处理大文件,你就掌握了在 Python 中安全读写文件、从容面对运行时异常的核心技能。下一课 22.对象的序列化和反序列化.md 将在此基础上,把对象整体"腌咸菜"(pickle/json),进一步延伸数据持久化的边界。

登录后查看全文
Python-100-Days