Python 系列(四十八) 搞懂 bytes、bytearray 和二进制读写
1 | 作者:李晓辉 |
- Python 系列(一) 从零开始认识 Python,入门其实没那么难
- Python 系列(二) 学 Python 的第一步,把开发环境搭建好
- Python 系列(三) 基础语法避坑指南,缩进、大小写、续行与注释
- Python 系列(四) Python 输入输出详解,print()、input() 与常见避坑指南
- Python 系列(五) 数值类型与运算符详解,整除、浮点精度与进制转换实战
- Python 系列(六) 字符串处理实战,查找、替换、切片与日志分析
- Python 系列(七) 类型转换详解,int()、float()、str() 与金额计算避坑
- Python 系列(八) Python 流程控制入门,掌握 if、elif 和 else
- Python 系列(九) Python 逻辑运算符详解,and、or、not 一篇搞懂
- Python 系列(十) while、for 循环实战,彻底搞懂 range、break 与 continue
- Python 系列(十一) list、tuple、set、dict,到底该怎么选?
- Python 系列(十二) List 列表实战,搞定数据增删改查与批量处理
- Python 系列(十三) Tuple 元组实战,搞懂不可变特性与 enumerate() 遍历
- Python 系列(十四) Set 集合实战,搞定数据去重与集合运算
- Python 系列(十五) Dict 字典详解,掌握快速查找与键值操作
- Python 系列(十六) sort() 和 sorted() 详解,掌握数据排序技巧
- Python 系列(十七) 自定义函数入门,让重复代码一次编写多次使用
- Python 系列(十八) 函数参数详解,搞懂位置参数、关键字参数和默认参数
- Python 系列(十九) 从 *args 到 **kwargs,让函数调用更灵活
- Python 系列(二十) 变量作用域是什么?一文搞懂 global 和 nonlocal
- Python 系列(二十一) 搞懂变量、对象和引用,理解函数参数传递机制
- Python 系列(二十二) map() 和 filter() 详解,轻松实现批量数据处理与筛选
- Python 系列(二十三) 嵌套函数与 lambda 表达式详解,搞懂作用域与闭包
- Python 系列(二十四) 搞懂递归函数,从基线条件到实际应用
- Python 系列(二十五) 聊聊模块与 import,让代码告别重复编写
- Python 系列(二十六) Python 模块入门,从 import 到模块复用
- Python 系列(二十七) Python dir() 函数详解,快速查看模块中的函数与属性
- Python 系列(二十八) 不会用 Python 标准库?教你快速查找模块和函数
- Python 系列(二十九) 搞懂 Python sys 模块,让脚本更适合自动化运维
- Python 系列(三十) 为什么 0.1 + 0.2 不等于 0.3?彻底搞懂 Decimal
- Python 系列(三十一) 搞懂 datetime 日期时间,轻松处理时区与时间计算
- Python 系列(三十二) 面向对象编程入门,从零理解类、对象、封装与继承
- Python 系列(三十三) 什么是类和对象?零基础理解 Python class
- Python 系列(三十四) Python 属性详解,搞懂类属性、实例属性和 setter
- Python 系列(三十五) 学会特殊方法,让对象打印出你想要的内容
- Python 系列(三十六) 多个对象如何共享数据?彻底搞懂类变量
- Python 系列(三十七) 搞懂父类、子类和方法重写,真正理解继承与多态
- Python 系列(三十八) 如何判断数据类型?三个内置函数一次讲明白
- Python 系列(三十九) 搞懂异常模型与常见异常类型,读懂程序报错
- Python 系列(四十) 从零学会异常处理,掌握 try、except、else 和 finally
- Python 系列(四十一) ValueError、TypeError 到底是什么?一文搞懂常见异常
- Python 系列(四十二) ValueError、TypeError 到底是什么?一文搞懂常见异常
- Python 系列(四十三) 别只会处理异常!学会自定义异常,让程序更容易维护
- Python 系列(四十四) 从零认识 assert 断言,让程序学会检查自己
- Python 系列(四十五) 使用 sys 模块处理标准输入与输出
- Python 系列(四十六) 使用 open() 读取和写入文件
- Python 系列(四十七) 学会读写文本文件,让程序保存运行结果
- Python 系列(四十八) 搞懂 bytes、bytearray 和二进制读写
- Python 系列(四十九) 使用 seek() 和 tell() 灵活控制文件读取位置
- Python 系列(五十) 掌握 os 和 os.path,实现文件与目录自动化管理
- Python 系列(五十一) 掌握列表推导式,轻松实现列表生成与数据筛选
- Python 系列(五十二) 掌握字典推导式,让字典创建更简单
- Python 系列(五十三) 从零理解生成器,轻松处理大量数据
- Python 系列(五十五) 使用 zip() 同时遍历多个集合
前面我们学习了如何使用 Python 读取和写入文本文件。
例如,我们可以读取服务器日志、读取配置文件,也可以把程序运行结果保存到文本文件中。
但在实际工作中,并不是所有文件都能按照普通文本的方式处理。
比如:
- 图片文件:
.png、.jpg - 压缩文件:
.zip、.gz - PDF 文档:
.pdf - 音频文件:
.mp3 - 程序或设备生成的二进制数据
如果我们尝试把这些文件都当作普通文本读取,就可能遇到乱码,甚至出现解码错误。
那么,Python 应该如何读取和写入这些文件呢?
这就是本篇要学习的内容:二进制文件操作,以及 bytes 和 bytearray 这两种数据类型。
为什么需要二进制文件操作
先来看一个生活中的例子。
假设我们需要编写一个 Python 程序,把服务器上的一张图片读取出来,再复制到另一个目录。
图片并不是由普通文字组成的。它的内容通常是按照特定格式组织的字节数据,里面包含了图像信息、文件结构等内容。
如果我们使用文本模式读取图片,Python 会尝试按照某种字符编码解释文件中的内容。
但图片中的字节不一定符合这种编码规则,因此可能出现解码错误。
我们可以用下面的程序进行尝试。
1 | with open("img.png", "r", encoding="utf-8") as f: |
这里的 img.png 是示例文件名。运行程序前,需要确保当前目录中存在这张图片。
如果图片包含不符合 UTF-8 编码规则的字节,程序就可能报出 UnicodeDecodeError。
即使没有报错,也不代表把图片当作文本读取就是正确的做法。
那么,正确的方式是什么?
答案是:使用二进制模式打开文件。
使用二进制模式读取文件
在 Python 中,open() 函数除了可以使用文本模式,还可以使用二进制模式。
常见的文件打开模式如下:
| 模式 | 含义 |
|---|---|
r | 以文本模式读取文件 |
w | 以文本模式写入文件,会覆盖原有内容 |
rb | 以二进制模式读取文件 |
wb | 以二进制模式写入文件,会覆盖原有内容 |
ab | 以二进制模式追加数据 |
这里的 b 是英文 binary 的缩写,表示二进制模式。
例如:
1 | with open("img.png", "rb") as f: |
这段代码做了什么?
open("img.png", "rb"):以二进制读取模式打开图片。f.read():读取文件中的全部字节数据。type(data):查看读取结果的数据类型。len(data):查看读取结果包含多少个字节。
假设图片大小为 1024 字节,运行结果可能是:
1 | <class 'bytes'> |
实际结果取决于图片的大小。
注意,二进制模式读取文件后,得到的不是普通字符串,而是 bytes 类型的数据。
接下来,我们就来认识它。
认识 bytes:不可变的字节序列
我们之前学习过字符串,例如:
1 | message = "Hello Python" |
输出结果:
1 | Hello Python |
字符串 str 用来表示文本,例如服务器名称、错误信息和日志内容。
而 bytes 用来表示一串字节数据,可以用于保存图片内容、压缩文件内容,以及其他需要按照原始字节处理的数据。
我们可以手动创建一个 bytes 对象:
1 | data = bytes([72, 101, 108, 108, 111]) |
输出结果:
1 | b'Hello' |
这里的 72、101、108、108、111 是字节的数值。
这些数值恰好对应 ASCII 编码中 Hello 这几个英文字母的编码,所以 Python 在显示时将它们呈现为可读的字符。
如果字节数据中包含不能直接显示的内容,Python 则可能使用转义形式显示。
例如:
1 | data = bytes([65, 66, 67, 0, 255]) |
输出结果:
1 | b'ABC\x00\xff' |
其中:
65、66、67对应字符A、B、C。\x00表示数值为 0 的字节。\xff表示数值为 255 的字节。len(data)返回字节序列的长度,这里是 5。
为什么要特别学习 bytes?
因为读取图片、压缩包等二进制文件时,Python 返回的就是这种类型。理解它,才能继续学习文件复制、二进制数据处理等操作。
bytes 是不可变的
bytes 有一个重要特点:创建之后,其中的字节不能直接修改。
例如:
1 | data = bytes([65, 66, 67]) |
运行时会出现类似下面的错误:
1 | TypeError: 'bytes' object does not support item assignment |
这是因为 bytes 是不可变类型。
如果我们需要修改其中的某个字节,就要使用另一种数据类型:bytearray。
认识 bytearray:可变的字节序列
bytearray 与 bytes 很相似,同样用于保存字节数据。
它们最重要的区别是:
bytes:不可变,创建后不能直接修改其中的字节。bytearray:可变,可以修改、添加或删除字节。
我们来看一个例子。
1 | data = bytearray([65, 66, 67]) |
输出结果:
1 | bytearray(b'ABC') |
现在,我们尝试修改第一个字节。
1 | data = bytearray([65, 66, 67]) |
输出结果:
1 | bytearray(b'ZBC') |
原来的第一个字节是 65,对应字符 A。修改为 90 后,就变成了字符 Z。
我们还可以在末尾添加一个字节:
1 | data = bytearray([65, 66, 67]) |
输出结果:
1 | bytearray(b'ABCD') |
append() 用于在末尾添加一个元素。这里添加的 68 是一个合法的字节数值,对应字符 D。
注意,单个字节的取值范围是 0 到 255。例如,data.append(300) 就会因为数值超出范围而报错。
bytes 和 bytearray 应该如何选择
| 对比项目 | bytes | bytearray |
|---|---|---|
| 是否可变 | 不可变 | 可变 |
| 读取二进制文件时常见的返回类型 | 是 | 不是默认返回类型 |
| 是否可以修改单个字节 | 不可以 | 可以 |
| 常见用途 | 保存、传递原始字节数据 | 需要修改的字节数据 |
对于初学者来说,可以先记住一个简单的原则:
如果只是读取文件、复制文件或者传递二进制数据,通常使用 bytes 就够了。
如果需要直接修改字节内容,可以考虑使用 bytearray。
不过,能够修改字节,并不代表我们可以随意修改图片或压缩文件的内容。许多文件都有自己的格式规范,随意更改其中的字节可能导致文件损坏。
实战:使用 Python 复制图片文件
认识了二进制文件和两种字节类型之后,我们来完成一个实际任务。
假设当前目录中有一张图片 img.png,现在希望把它复制为 img_copy.png。
在 Linux 运维工作中,这类操作可以用于备份文件、复制归档数据,或者处理程序生成的二进制文件。
先准备好目录和图片文件。然后创建一个名为 copy_image.py 的 Python 文件,将下面的代码写进去:
1 | # 以二进制模式读取原始图片 |
这里需要注意,img.png 必须是当前目录中真实存在的文件。如果图片位于其他目录,可以使用完整路径或正确的相对路径。
运行程序:
1 | python copy_image.py |
输出结果可能如下:
1 | 图片复制完成! |
这里的 102400 只是示例,实际字节数取决于原始图片的大小。
执行结束后,当前目录中会多出一个 img_copy.png 文件。
逐步理解这段代码
第一步,读取原始文件:
1 | with open("img.png", "rb") as source: |
rb 表示以二进制模式读取文件。
读取完成后,data 保存的是 bytes 对象,其中包含原始图片的字节数据。
第二步,创建目标文件并写入数据:
1 | with open("img_copy.png", "wb") as target: |
wb 表示以二进制模式写入文件。
如果目标文件不存在,Python 会创建它;如果目标文件已经存在,原有内容会被覆盖。
write(data) 则把 data 中的字节写入目标文件。
因为读取和写入都使用二进制模式,所以 Python 不会把这些数据当作普通文本进行字符编码和解码。
第三步,为什么复制后图片通常能够正常打开?
因为程序将读取到的字节数据原样写入了新文件,没有把图片内容转换成字符串,也没有主动改变其中的字节。
只要读取完整、写入成功,目标文件就可以保留原始文件的内容。
实战进阶:使用 bytearray 修改二进制数据
接下来,我们再做一个小实验,观察 bytes 和 bytearray 在文件操作中的区别。
创建 modify_binary.py 文件:
1 | # 创建一组字节数据 |
运行程序:
1 | python modify_binary.py |
输出结果:
1 | 原始数据: b'ABC' |
程序还会生成一个 result.bin 文件。
这个例子展示了三个重要步骤:
- 使用
bytes创建原始字节数据。 - 使用
bytearray()将其转换为可修改的字节序列。 - 使用
wb将修改后的数据写入二进制文件。
需要注意,bytearray(data) 会创建一个新的可变字节序列。修改 mutable_data 不会反过来修改原来的 data。
这个实验中的数据只有三个字节,因此容易观察变化。在真实的图片、压缩包等文件中,即使只修改一个字节,也可能破坏文件结构,所以不要直接拿重要文件做随意修改的实验。
二进制文件操作中的常见问题
为什么不能使用 print() 来复制文件?
print() 的主要作用是把内容输出到屏幕或其他文本流中。
如果要将原始二进制数据写入文件,应该使用文件对象的 write() 方法。
例如:
1 | data = bytes([65, 66, 67]) |
这里写入的是三个字节,而不是把某个 Python 对象的显示形式打印到文件中。
为什么写入二进制文件时不能直接写字符串?
例如:
1 | with open("result.bin", "wb") as f: |
这段代码会报错,因为二进制模式下的 write() 需要字节类数据,而不是 str 字符串。
如果确实需要将文本写入二进制文件,可以先进行编码:
1 | data = "Hello".encode("utf-8") |
这里的 encode("utf-8") 会把字符串转换成 UTF-8 编码的 bytes 对象。
注意,文本编码后的字节与原始图片、压缩包中的字节并不是同一回事。具体如何解释这些字节,取决于对应的数据格式。
为什么要使用 with open()?
我们前面已经学习过 with open(),这里再结合二进制文件复习一下。
1 | with open("img.png", "rb") as f: |
with 可以帮助我们在退出代码块时自动关闭文件,即使读取过程中出现异常,也能更好地管理文件资源。
无论处理文本文件还是二进制文件,都推荐优先使用这种写法。
动手练习
建议自己创建几个小程序,亲自运行一遍。
- 练习一: 读取一张图片,使用
type()查看返回的数据类型。 - 练习二: 把图片复制为另一个文件,并确认新文件能够正常打开。
- 练习三: 创建一个
bytearray,将其中一个字节修改为其他合法数值。 - 练习四: 使用
encode()把字符串转换为bytes,再将它写入二进制文件。 - 练习五: 尝试使用
rb读取一个不存在的文件,观察 Python 报出的异常。
完成这些练习后,你应该能够理解二进制文件的基本读写方式,以及 bytes 和 bytearray 的区别。
写在最后
本篇我们学习了 Python 的二进制文件操作。
现在,你应该知道:
rb用于以二进制模式读取文件。wb用于以二进制模式写入文件。bytes是不可变的字节序列。bytearray是可变的字节序列。- 读取图片等二进制文件时,不应该把文件内容当作普通文本处理。
不过,目前我们读取文件时,主要使用的是 read() 一次性读取全部内容。
如果文件很大,例如一个数 GB 的压缩包或者大型备份文件,一次性读取全部内容可能会占用大量内存。
有没有办法不必一次性读取整个文件,而是先查看当前位置,再跳转到文件中的指定位置读取数据?
下一篇,我们将学习 seek() 和 tell(),了解如何控制文件读取位置,为后续处理大文件打下基础。
