本节书摘来自异步社区《Python Cookbook(第3版)中文版》一书中的第6章,第6.11节,作者[美]David Beazley , Brian K.Jones,陈舸 译,更多章节内容可以访问云栖社区“异步社区”公众号查看。
6.11 读写二进制结构的数组
6.11.1 问题
我们想将数据编码为统一结构的二进制数组,然后将这些数据读写到Python元组中去。
6.11.2 解决方案
要同二进制数据打交道的话,我们可以使用struct模块。下面的示例将一列Python元组写入到一个二进制文件中,通过struct模块将每个元组编码为一个结构。
from struct import Struct
def write_records(records, format, f):
'''
Write a sequence of tuples to a binary file of structures.
'''
record_struct = Struct(format)
for r in records:
f.write(record_struct.pack(*r))
# Example
if __name__ == '__main__':
records = [ (1, 2.3, 4.5),
(6, 7.8, 9.0),
(12, 13.4, 56.7) ]
with open('data.b', 'wb') as f:
write_records(records, '<idd', f)
如果要将这个文件重新读取为一列Python元组的话,有好几种方法可以实现。首先,如果打算按块以增量式的方式读取文件的话,可以按照下面的示例来实现:
from struct import Struct
def read_records(format, f):
record_struct = Struct(format)
chunks = iter(lambda: f.read(record_struct.size), b'')
return (record_struct.unpack(chunk) for chunk in chunks)
# Example
if __name__ == '__main__':
with open('data.b','rb') as f:
for rec in read_records('<idd', f):
# Process rec
...
如果只想用一个read()调用将文件全部读取到一个字节串中,然后再一块一块的做转换,那么可以编写如下的代码:
from struct import Struct
def unpack_records(format, data):
record_struct = Struct(format)
return (record_struct.unpack_from(data, offset)
for offset in range(0, len(data), record_struct.size))
# Example
if __name__ == '__main__':
with open('data.b', 'rb') as f:
data = f.read()
for rec in unpack_records('<idd', data):
# Process rec
...
在这两种情况下得到的结果都是一个可迭代对象,它能够产生出之前保存在文件中的那些元组。
6.11.3 讨论
对于那些必须对二进制数据编码和解码的程序,我们常会用到struct模块。要定义一个新的结构,只要简单地创建一个Struct实例即可:
# Little endian 32-bit integer, two double precision floats
record_struct = Struct('<idd')
结构总是通过一组结构化代码来定义,比如i、d、f等(参见Python的文档http://docs.python.org/3/library/struct.html )。这些代码同特定的二进制数据相对应,比如32位整数、64位浮点数、32位浮点数等。而第一个字符<指定了字节序。在这个例子中表示为“小端序”。将字符修改为>就表示为大端序,或者用!来表示网络字节序。
得到的Struct实例有着多种属性和方法,它们可用来操纵那种类型的结构。size属性包含了以字节为单位的结构体大小,这对于I/O操作来说是很有用的。pack()和unpack()方法是用来打包和解包数据的。示例如下:
>>> from struct import Struct
>>> record_struct = Struct('<idd')
>>> record_struct.size
20
>>> record_struct.pack(1, 2.0, 3.0)
b'\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00@\x00\x00\x00\x00\x00\x00\x08@'
>>> record_struct.unpack(_)
(1, 2.0, 3.0)
>>>
有时候我们会发现pack()和unpack()会以模块级函数(module-level functions)的形式调用,就像下面的示例这样:
>>> import struct
>>> struct.pack('<idd', 1, 2.0, 3.0)
b'\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00@\x00\x00\x00\x00\x00\x00\x08@'
>>> struct.unpack('<idd', _)
(1, 2.0, 3.0)
>>>
这么做行的通,但是比起创建一个单独的Struct实例来说还是显得不那么优雅,尤其是如果相同的结构会在代码中多处出现时。通过创建一个Struct实例,我们只用指定一次格式化代码,所有有用的操作都被漂亮地归组到了一起(通过实例方法来调用)。如果需要同结构打交道的话,这么做肯定会使得代码更容易维护(因为只需要修改一处即可)。
用来读取二进制结构的代码中涉及一些有趣而且优雅的编程惯用法(programming idioms)。在函数read_records()中,我们用iter()来创建一个迭代器,使其返回固定大小的数据块(参见5.8节)。这个迭代器会重复调用一个用户提供的可调用对象(即,lambda: f.read(record_struct.size))直到它返回一个指定值为止(即,b''),此时迭代过程结束。示例如下:
>>> f = open('data.b', 'rb')
>>> chunks = iter(lambda: f.read(20), b'')
>>> chunks
<callable_iterator object at 0x10069e6d0>
>>> for chk in chunks:
... print(chk)
...
b'\x01\x00\x00\x00ffffff\x02@\x00\x00\x00\x00\x00\x00\x12@'
b'\x06\x00\x00\x00333333\x1f@\x00\x00\x00\x00\x00\x00"@'
b'\x0c\x00\x00\x00\xcd\xcc\xcc\xcc\xcc\xcc*@\x9a\x99\x99\x99\x99YL@'
>>>
创建一个可迭代对象的原因之一在于这么做允许我们通过一个生成器表达式来创建records记录,就像解决方案中展示的那样。如果不采用这种方式,那么代码看起来就会像这样:
def read_records(format, f):
record_struct = Struct(format)
while True:
chk = f.read(record_struct.size)
if chk == b'':
break
yield record_struct.unpack(chk)
return records
在函数unpack_records()中我们采用了另一种方法。这里使用的unpack_from()方法对于从大型的二进制数组中提取出二进制数据是非常有用的,因为它不会创建任何临时对象或者执行内存拷贝动作。我们只需提供一个字节串(或者任意的数组),再加上一个字节偏移量,它就能直接从那个位置上将字段解包出来。
如果用的是unpack()而不是unpack_from(),那么需要修改代码,创建许多小的切片对象并且还要计算偏移量。示例如下:
def unpack_records(format, data):
record_struct = Struct(format)
return (record_struct.unpack(data[offset:offset + record_struct.size])
for offset in range(0, len(data), record_struct.size))
这个版本的实现除了读取数据变得更加复杂之外,还需要完成许多工作,因为它得计算很多偏移量,拷贝数据,创建小的切片对象。如果打算从已读取的大型字节串中解包出许多结构的话,那么unpack_from()是更加优雅的方案。
我们可能会想在解包记录时利用collections模块中的namedtuple对象。这么做允许我们在返回的元组上设定属性名。示例如下:
from collections import namedtuple
Record = namedtuple('Record', ['kind','x','y'])
with open('data.p', 'rb') as f:
records = (Record(*r) for r in read_records('<idd', f))
for r in records:
print(r.kind, r.x, r.y)
如果正在编写一个需要同大量的二进制数据打交道的程序,最好使用像numpy这样的库。比如,与其将二进制数据读取到元组列表中,不如直接将数据读入到结构化的数组中,就像这样:
>>> import numpy as np
>>> f = open('data.b', 'rb')
>>> records = np.fromfile(f, dtype='<i,<d,<d')
>>> records
array([(1, 2.3, 4.5), (6, 7.8, 9.0), (12, 13.4, 56.7)],
dtype=[('f0', '<i4'), ('f1', '<f8'), ('f2', '<f8')])
>>> records[0]
(1, 2.3, 4.5)
>>> records[1]
(6, 7.8, 9.0)
>>>
最后但同样重要的是,如果面对的任务是从某种已知的文件结构中读取二进制数据(例如图像格式、shapefile、HDF5等),请先检查是否已有相应的Python模块可用。没必的话就别去重复发明轮子了。