1
2
3
4
5
6
7
作者:李晓辉

联系方式:

1. 微信:Lxh_Chat

2. 邮箱:939958092@qq.com
  1. Python 系列(一) 从零开始认识 Python,入门其实没那么难
  2. Python 系列(二) 学 Python 的第一步,把开发环境搭建好
  3. Python 系列(三) 基础语法避坑指南,缩进、大小写、续行与注释
  4. Python 系列(四) Python 输入输出详解,print()、input() 与常见避坑指南
  5. Python 系列(五) 数值类型与运算符详解,整除、浮点精度与进制转换实战
  6. Python 系列(六) 字符串处理实战,查找、替换、切片与日志分析
  7. Python 系列(七) 类型转换详解,int()、float()、str() 与金额计算避坑
  8. Python 系列(八) Python 流程控制入门,掌握 if、elif 和 else
  9. Python 系列(九) Python 逻辑运算符详解,and、or、not 一篇搞懂
  10. Python 系列(十) while、for 循环实战,彻底搞懂 range、break 与 continue
  11. Python 系列(十一) list、tuple、set、dict,到底该怎么选?
  12. Python 系列(十二) List 列表实战,搞定数据增删改查与批量处理
  13. Python 系列(十三) Tuple 元组实战,搞懂不可变特性与 enumerate() 遍历
  14. Python 系列(十四) Set 集合实战,搞定数据去重与集合运算
  15. Python 系列(十五) Dict 字典详解,掌握快速查找与键值操作
  16. Python 系列(十六) sort() 和 sorted() 详解,掌握数据排序技巧
  17. Python 系列(十七) 自定义函数入门,让重复代码一次编写多次使用
  18. Python 系列(十八) 函数参数详解,搞懂位置参数、关键字参数和默认参数
  19. Python 系列(十九) 从 *args 到 **kwargs,让函数调用更灵活
  20. Python 系列(二十) 变量作用域是什么?一文搞懂 global 和 nonlocal
  21. Python 系列(二十一) 搞懂变量、对象和引用,理解函数参数传递机制
  22. Python 系列(二十二) map() 和 filter() 详解,轻松实现批量数据处理与筛选
  23. Python 系列(二十三) 嵌套函数与 lambda 表达式详解,搞懂作用域与闭包
  24. Python 系列(二十四) 搞懂递归函数,从基线条件到实际应用
  25. Python 系列(二十五) 聊聊模块与 import,让代码告别重复编写
  26. Python 系列(二十六) Python 模块入门,从 import 到模块复用
  27. Python 系列(二十七) Python dir() 函数详解,快速查看模块中的函数与属性
  28. Python 系列(二十八) 不会用 Python 标准库?教你快速查找模块和函数
  29. Python 系列(二十九) 搞懂 Python sys 模块,让脚本更适合自动化运维
  30. Python 系列(三十) 为什么 0.1 + 0.2 不等于 0.3?彻底搞懂 Decimal
  31. Python 系列(三十一) 搞懂 datetime 日期时间,轻松处理时区与时间计算
  32. Python 系列(三十二) 面向对象编程入门,从零理解类、对象、封装与继承
  33. Python 系列(三十三) 什么是类和对象?零基础理解 Python class
  34. Python 系列(三十四) Python 属性详解,搞懂类属性、实例属性和 setter
  35. Python 系列(三十五) 学会特殊方法,让对象打印出你想要的内容
  36. Python 系列(三十六) 多个对象如何共享数据?彻底搞懂类变量
  37. Python 系列(三十七) 搞懂父类、子类和方法重写,真正理解继承与多态
  38. Python 系列(三十八) 如何判断数据类型?三个内置函数一次讲明白
  39. Python 系列(三十九) 搞懂异常模型与常见异常类型,读懂程序报错
  40. Python 系列(四十) 从零学会异常处理,掌握 try、except、else 和 finally
  41. Python 系列(四十一) ValueError、TypeError 到底是什么?一文搞懂常见异常
  42. Python 系列(四十二) ValueError、TypeError 到底是什么?一文搞懂常见异常
  43. Python 系列(四十三) 别只会处理异常!学会自定义异常,让程序更容易维护
  44. Python 系列(四十四) 从零认识 assert 断言,让程序学会检查自己
  45. Python 系列(四十五) 使用 sys 模块处理标准输入与输出
  46. Python 系列(四十六) 使用 open() 读取和写入文件
  47. Python 系列(四十七) 学会读写文本文件,让程序保存运行结果
  48. Python 系列(四十八) 搞懂 bytes、bytearray 和二进制读写
  49. Python 系列(四十九) 使用 seek() 和 tell() 灵活控制文件读取位置
  50. Python 系列(五十) 掌握 os 和 os.path,实现文件与目录自动化管理
  51. Python 系列(五十一) 掌握列表推导式,轻松实现列表生成与数据筛选
  52. Python 系列(五十二) 掌握字典推导式,让字典创建更简单
  53. Python 系列(五十三) 从零理解生成器,轻松处理大量数据
  54. Python 系列(五十五) 使用 zip() 同时遍历多个集合

前面我们学习了如何使用 Python 读取和写入文本文件。

例如,我们可以读取服务器日志、读取配置文件,也可以把程序运行结果保存到文本文件中。

但在实际工作中,并不是所有文件都能按照普通文本的方式处理。

比如:

  • 图片文件:.png、.jpg
  • 压缩文件:.zip、.gz
  • PDF 文档:.pdf
  • 音频文件:.mp3
  • 程序或设备生成的二进制数据

如果我们尝试把这些文件都当作普通文本读取,就可能遇到乱码,甚至出现解码错误。

那么,Python 应该如何读取和写入这些文件呢?

这就是本篇要学习的内容:二进制文件操作,以及 bytes 和 bytearray 这两种数据类型。

为什么需要二进制文件操作

先来看一个生活中的例子。

假设我们需要编写一个 Python 程序,把服务器上的一张图片读取出来,再复制到另一个目录。

图片并不是由普通文字组成的。它的内容通常是按照特定格式组织的字节数据,里面包含了图像信息、文件结构等内容。

如果我们使用文本模式读取图片,Python 会尝试按照某种字符编码解释文件中的内容。

但图片中的字节不一定符合这种编码规则,因此可能出现解码错误。

我们可以用下面的程序进行尝试。

1
2
3
4
with open("img.png", "r", encoding="utf-8") as f:
data = f.read()

print(data)

这里的 img.png 是示例文件名。运行程序前,需要确保当前目录中存在这张图片。

如果图片包含不符合 UTF-8 编码规则的字节,程序就可能报出 UnicodeDecodeError。

即使没有报错,也不代表把图片当作文本读取就是正确的做法。

那么,正确的方式是什么?

答案是:使用二进制模式打开文件。

使用二进制模式读取文件

在 Python 中,open() 函数除了可以使用文本模式,还可以使用二进制模式。

常见的文件打开模式如下:

模式含义
r以文本模式读取文件
w以文本模式写入文件,会覆盖原有内容
rb以二进制模式读取文件
wb以二进制模式写入文件,会覆盖原有内容
ab以二进制模式追加数据

这里的 b 是英文 binary 的缩写,表示二进制模式。

例如:

1
2
3
4
5
with open("img.png", "rb") as f:
data = f.read()

print(type(data))
print(len(data))

这段代码做了什么?

  • open("img.png", "rb"):以二进制读取模式打开图片。
  • f.read():读取文件中的全部字节数据。
  • type(data):查看读取结果的数据类型。
  • len(data):查看读取结果包含多少个字节。

假设图片大小为 1024 字节,运行结果可能是:

1
2
<class 'bytes'>
1024

实际结果取决于图片的大小。

注意,二进制模式读取文件后,得到的不是普通字符串,而是 bytes 类型的数据。

接下来,我们就来认识它。

认识 bytes:不可变的字节序列

我们之前学习过字符串,例如:

1
2
3
4
message = "Hello Python"

print(message)
print(type(message))

输出结果:

1
2
Hello Python
<class 'str'>

字符串 str 用来表示文本,例如服务器名称、错误信息和日志内容。

而 bytes 用来表示一串字节数据,可以用于保存图片内容、压缩文件内容,以及其他需要按照原始字节处理的数据。

我们可以手动创建一个 bytes 对象:

1
2
3
4
data = bytes([72, 101, 108, 108, 111])

print(data)
print(type(data))

输出结果:

1
2
b'Hello'
<class 'bytes'>

这里的 72、101、108、108、111 是字节的数值。

这些数值恰好对应 ASCII 编码中 Hello 这几个英文字母的编码,所以 Python 在显示时将它们呈现为可读的字符。

如果字节数据中包含不能直接显示的内容,Python 则可能使用转义形式显示。

例如:

1
2
3
4
data = bytes([65, 66, 67, 0, 255])

print(data)
print(len(data))

输出结果:

1
2
b'ABC\x00\xff'
5

其中:

  • 65、66、67 对应字符 A、B、C。
  • \x00 表示数值为 0 的字节。
  • \xff 表示数值为 255 的字节。
  • len(data) 返回字节序列的长度,这里是 5。

为什么要特别学习 bytes?

因为读取图片、压缩包等二进制文件时,Python 返回的就是这种类型。理解它,才能继续学习文件复制、二进制数据处理等操作。

bytes 是不可变的

bytes 有一个重要特点:创建之后,其中的字节不能直接修改。

例如:

1
2
3
4
5
data = bytes([65, 66, 67])

print(data)

data[0] = 90

运行时会出现类似下面的错误:

1
TypeError: 'bytes' object does not support item assignment

这是因为 bytes 是不可变类型。

如果我们需要修改其中的某个字节,就要使用另一种数据类型:bytearray。

认识 bytearray:可变的字节序列

bytearray 与 bytes 很相似,同样用于保存字节数据。

它们最重要的区别是:

  • bytes:不可变,创建后不能直接修改其中的字节。
  • bytearray:可变,可以修改、添加或删除字节。

我们来看一个例子。

1
2
3
4
data = bytearray([65, 66, 67])

print(data)
print(type(data))

输出结果:

1
2
bytearray(b'ABC')
<class 'bytearray'>

现在,我们尝试修改第一个字节。

1
2
3
4
5
data = bytearray([65, 66, 67])

data[0] = 90

print(data)

输出结果:

1
bytearray(b'ZBC')

原来的第一个字节是 65,对应字符 A。修改为 90 后,就变成了字符 Z。

我们还可以在末尾添加一个字节:

1
2
3
4
5
data = bytearray([65, 66, 67])

data.append(68)

print(data)

输出结果:

1
bytearray(b'ABCD')

append() 用于在末尾添加一个元素。这里添加的 68 是一个合法的字节数值,对应字符 D。

注意,单个字节的取值范围是 0 到 255。例如,data.append(300) 就会因为数值超出范围而报错。

bytes 和 bytearray 应该如何选择

对比项目bytesbytearray
是否可变不可变可变
读取二进制文件时常见的返回类型是不是默认返回类型
是否可以修改单个字节不可以可以
常见用途保存、传递原始字节数据需要修改的字节数据

对于初学者来说,可以先记住一个简单的原则:

如果只是读取文件、复制文件或者传递二进制数据,通常使用 bytes 就够了。

如果需要直接修改字节内容,可以考虑使用 bytearray。

不过,能够修改字节,并不代表我们可以随意修改图片或压缩文件的内容。许多文件都有自己的格式规范,随意更改其中的字节可能导致文件损坏。

实战:使用 Python 复制图片文件

认识了二进制文件和两种字节类型之后,我们来完成一个实际任务。

假设当前目录中有一张图片 img.png,现在希望把它复制为 img_copy.png。

在 Linux 运维工作中,这类操作可以用于备份文件、复制归档数据,或者处理程序生成的二进制文件。

先准备好目录和图片文件。然后创建一个名为 copy_image.py 的 Python 文件,将下面的代码写进去:

1
2
3
4
5
6
7
8
9
10
# 以二进制模式读取原始图片
with open("img.png", "rb") as source:
data = source.read()

# 以二进制模式写入新文件
with open("img_copy.png", "wb") as target:
target.write(data)

print("图片复制完成!")
print(f"读取的字节数:{len(data)}")

这里需要注意,img.png 必须是当前目录中真实存在的文件。如果图片位于其他目录,可以使用完整路径或正确的相对路径。

运行程序:

1
python copy_image.py

输出结果可能如下:

1
2
图片复制完成!
读取的字节数:102400

这里的 102400 只是示例,实际字节数取决于原始图片的大小。

执行结束后,当前目录中会多出一个 img_copy.png 文件。

逐步理解这段代码

第一步,读取原始文件:

1
2
with open("img.png", "rb") as source:
data = source.read()

rb 表示以二进制模式读取文件。

读取完成后,data 保存的是 bytes 对象,其中包含原始图片的字节数据。

第二步,创建目标文件并写入数据:

1
2
with open("img_copy.png", "wb") as target:
target.write(data)

wb 表示以二进制模式写入文件。

如果目标文件不存在,Python 会创建它;如果目标文件已经存在,原有内容会被覆盖。

write(data) 则把 data 中的字节写入目标文件。

因为读取和写入都使用二进制模式,所以 Python 不会把这些数据当作普通文本进行字符编码和解码。

第三步,为什么复制后图片通常能够正常打开?

因为程序将读取到的字节数据原样写入了新文件,没有把图片内容转换成字符串,也没有主动改变其中的字节。

只要读取完整、写入成功,目标文件就可以保留原始文件的内容。

实战进阶:使用 bytearray 修改二进制数据

接下来,我们再做一个小实验,观察 bytes 和 bytearray 在文件操作中的区别。

创建 modify_binary.py 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 创建一组字节数据
data = bytes([65, 66, 67])

print("原始数据:", data)

# 转换成可修改的 bytearray
mutable_data = bytearray(data)

# 修改第一个字节
mutable_data[0] = 90

print("修改后的数据:", mutable_data)

# 将修改后的字节写入文件
with open("result.bin", "wb") as f:
f.write(mutable_data)

print("文件写入完成!")

运行程序:

1
python modify_binary.py

输出结果:

1
2
3
原始数据: b'ABC'
修改后的数据: bytearray(b'ZBC')
文件写入完成!

程序还会生成一个 result.bin 文件。

这个例子展示了三个重要步骤:

  1. 使用 bytes 创建原始字节数据。
  2. 使用 bytearray() 将其转换为可修改的字节序列。
  3. 使用 wb 将修改后的数据写入二进制文件。

需要注意,bytearray(data) 会创建一个新的可变字节序列。修改 mutable_data 不会反过来修改原来的 data。

这个实验中的数据只有三个字节,因此容易观察变化。在真实的图片、压缩包等文件中,即使只修改一个字节,也可能破坏文件结构,所以不要直接拿重要文件做随意修改的实验。

二进制文件操作中的常见问题

为什么不能使用 print() 来复制文件?

print() 的主要作用是把内容输出到屏幕或其他文本流中。

如果要将原始二进制数据写入文件,应该使用文件对象的 write() 方法。

例如:

1
2
3
4
data = bytes([65, 66, 67])

with open("result.bin", "wb") as f:
f.write(data)

这里写入的是三个字节,而不是把某个 Python 对象的显示形式打印到文件中。

为什么写入二进制文件时不能直接写字符串?

例如:

1
2
with open("result.bin", "wb") as f:
f.write("Hello")

这段代码会报错,因为二进制模式下的 write() 需要字节类数据,而不是 str 字符串。

如果确实需要将文本写入二进制文件,可以先进行编码:

1
2
3
4
data = "Hello".encode("utf-8")

with open("result.bin", "wb") as f:
f.write(data)

这里的 encode("utf-8") 会把字符串转换成 UTF-8 编码的 bytes 对象。

注意,文本编码后的字节与原始图片、压缩包中的字节并不是同一回事。具体如何解释这些字节,取决于对应的数据格式。

为什么要使用 with open()?

我们前面已经学习过 with open(),这里再结合二进制文件复习一下。

1
2
with open("img.png", "rb") as f:
data = f.read()

with 可以帮助我们在退出代码块时自动关闭文件,即使读取过程中出现异常,也能更好地管理文件资源。

无论处理文本文件还是二进制文件,都推荐优先使用这种写法。

动手练习

建议自己创建几个小程序,亲自运行一遍。

  • 练习一: 读取一张图片,使用 type() 查看返回的数据类型。
  • 练习二: 把图片复制为另一个文件,并确认新文件能够正常打开。
  • 练习三: 创建一个 bytearray,将其中一个字节修改为其他合法数值。
  • 练习四: 使用 encode() 把字符串转换为 bytes,再将它写入二进制文件。
  • 练习五: 尝试使用 rb 读取一个不存在的文件,观察 Python 报出的异常。

完成这些练习后,你应该能够理解二进制文件的基本读写方式,以及 bytes 和 bytearray 的区别。

写在最后

本篇我们学习了 Python 的二进制文件操作。

现在,你应该知道:

  • rb 用于以二进制模式读取文件。
  • wb 用于以二进制模式写入文件。
  • bytes 是不可变的字节序列。
  • bytearray 是可变的字节序列。
  • 读取图片等二进制文件时,不应该把文件内容当作普通文本处理。

不过,目前我们读取文件时,主要使用的是 read() 一次性读取全部内容。

如果文件很大,例如一个数 GB 的压缩包或者大型备份文件,一次性读取全部内容可能会占用大量内存。

有没有办法不必一次性读取整个文件,而是先查看当前位置,再跳转到文件中的指定位置读取数据?

下一篇,我们将学习 seek() 和 tell(),了解如何控制文件读取位置,为后续处理大文件打下基础。