前言
在网络通信当中,大多传递的数据是以二进制流存在的,比如很多格式的文件、音乐、流媒体等都是二进制流的形式。当传递字符串这种数据时,不必担心太多的问题,而当传递诸如int、char之类的基本数据的时候,就需要有一种机制将某些特定的结构体类型打包成二进制流的字符串然后再网络传输,而接收端也应该可以通过某种机制进行解包还原出原始的基本数据。
Python中的struct模块就提供了以上的这种机制,是比较常用的对象序列化和二进制读写模块。
在此模块中,最常用的两个函数是pack()和unpack(),很明显,这两个函数的作用也如同字面意义上一般,是互补的。前者“pack”意为“打包”,即把数据打包成二进制流的形式;后者“unpack”意为“解包”,即按照同样的形式将二进制流恢复为原来的数据。
下面,主要对这两个函数进行解释,最后配上例题更好地理解。
1、struct.pack()
(1) 格式
struct.pack(fmt,v1,v2,.....)
fmt是format的缩写,表示格式字符,v1、v2……是待打包的对象,可以是整型、浮点型等(后面会说)。即以“fmt”规定的格式对后面的对象进行打包,需要注意的是,后面的v1、v2等必须与fmt严格对应。
(2) 返回值
struct.pack()返回包装好后的“bytes”类型的字符串。
>>> import struct
>>> struct.pack("@i", 20)
b'\x14\x00\x00\x00'
>>> type(struct.pack("@i", 20))
<class 'bytes'>
(3) fmt格式字符的种类
通过规定mft的格式字符,才能实现准确将数据打包。
说明:
(1) q和Q只在64位操作系统上有效,表示8位整型;
(2) c只能表示长度为1的string,类似于C语言中的char;而s表示string,长度可以任意;
(3) 除“s”外,格式字符前面加上数字n表示个数,如fmt = “5i”表示5个整型,后面必须有连续5个整型对象对应,与fmt = “iiiii”等价。而在“s”格式字符前面加上数字表示对应字符串参数的长度,比如5s表示一个字符串型,该字符串长度为5,如果要表示连续5个长度为5的字符串,只能用fmt = “5s5s5s5s5s”。
(4) 在Python3.x版本中,无法再通过格式字符“c”或“s”表示字符串。如果字符串长度大于1,如str = “Hello”,无法通过struct.pack(“5s”, str) 的形式包装成二进制流,否则会报如下错误:
struct.pack("c", "s")
Traceback (most recent call last):
File "<input>", line 1, in <module>
struct.error: char format requires a bytes object of length 1
错误解释:char类型的格式要求长度为1的bytes类型对象
struct.pack("5s", "Hello")
Traceback (most recent call last):
File "<input>", line 1, in <module>
struct.error: argument for 's' must be a bytes object
错误解释:“s”对应的参数必须是bytes对象
也就是说,在Python3.x中,所有字符串类型的参数必须以字节流的形式送入pack()函数,才能进行包装。在这种情况下,要么先用bytes()函数把字符串转换成字节流的形式,要么直接使用string.encode(encoding = “xxx”)将字符串转换为UTF-8(或其他编码)字节对象。
事实上,在Python3.x中,字符串类型的对象都建议使用后面的方法。
下面给一个例子。
import struct
# 数据定义
m = -7 #整型
n = 32 #整型
t = False #布尔型
x = 5.43 #浮点型
c1 = 'a' #长度为1的字符串
c2 = 'b' #长度为1的字符串
s1 = "Hello" #字符串
s2 = "中国" #字符串
# 将整型、布尔型、浮点型数据使用pack()打包成二进制流
sn = struct.pack("2i?f", m, n, t, x)
# 将字符串数据使用encode()打包成字节对象
c1_encode = c1.encode("utf-8")
c2_encode = c2.encode("utf-8")
s1_encode = s1.encode("utf-8")
s2_encode = s2.encode("gbk")
# bytes型字节流
print("sn = {}".format(sn))
print("c1_encode = {}".format(c1_encode))
print("c2_encode = {}".format(c2_encode))
print("s1_encode = {}".format(s1_encode))
print("s2_encode = {}".format(s2_encode))
运行结果为:
sn = b'\xf9\xff\xff\xff \x00\x00\x00\x00\x00\x00\x00\x8f\xc2\xad@'
c1_encode = b'a'
c2_encode = b'b'
s1_encode = b'Hello'
s2_encode = b'\xd6\xd0\xb9\xfa'
2、struct.unpack()
(1) 格式
struct.unpack(fmt, <bytes> string)
对bytes类型的字符串进行“解包”操作,“解包”规则按照fmt指定的格式字符。
(2) 返回值
struct.unpack()返回结果为一个元组,是按照fmt规则对string解包得到的结果,即使其只包含一个条目。
下面给出一个例子(以上面的结果为基础)。
sn_unpack = struct.unpack("2i?f", sn)
c1_decode = c1_encode.decode("utf-8")
c2_decode = c2_encode.decode("utf-8")
s1_decode = s1_encode.decode("utf-8")
s2_decode = s2_encode.decode("gbk")
print("sn_unpack = {}".format(sn_unpack))
print("c1_decode = {}".format(c1_decode))
print("c2_decode = {}".format(c2_decode))
print("s1_decode = {}".format(s1_decode))
print("s2_decode = {}".format(s2_decode))
运行结果为:
sn_unpack = (-7, 32, False, 5.429999828338623)
c1_decode = a
c2_decode = b
s1_decode = Hello
s2_decode = 中国
3、struct.calcsize()
该函数用于计算pack()/unpack()中的参数mft对应的字节流大小,格式:
struct.calcsize(fmt)
下面给出一个例子:
>>> struct.calcsize("5i2?d3c4s5s")
44
解释一下,“5i”代表5个整型,一个整型占4字节;“2?”表示2个布尔型,一个不布尔型占一个字节;“d”表示浮点型,占4字节;“3c”表示3个长度为1字节的字符串类型;“4s5s”分别表示长度为4字节和5字节的字符串类型,共两个。
那么一共就是5×4+2×1+4+3×1+4+5 = 38bytes,但为什么结果是44呢?
这是因为在按字节流编码的时候,字节串型"c"和"s"、以及布尔型"?"可以从任何偏移量开始,因为它的标准大小为1byte,而对于整型、浮点型等只能以其标准大小的倍数偏移量开始。比方说,“i”表示4bytes整型,它就必须以4的倍数大小的偏移量开始计算;而“d”表示8bytes的浮点型,它就必须以8的倍数大小的偏移量开始计算。
下面来看我们的代码:
"5i2?d3c4s5s"中,最初的“5i”共占了20bytes,是4的整数倍,没问题。但是后面紧跟着的是“2?”,每个布尔型占1个字节,加起来需要2个字节,也没问题。接下来,一个"d"类型的浮点数,标准大小为8bytes,但是前面已经用了20+2 = 22,并非8的倍数,因此必须在其前面扩充两个空字节来凑够8的倍数24,这个时候才能填充"d"这8个bytes,到现在为止已经用了24+8 = 32bytes。后面的“c”和“s”都是以1bytes为标准单位的,直接计算就行了。
因此实际占用字节数 = 20+2+2+8+3+4+5 = 44bytes!!!
下面再来个例子更充分说明下:
struct.calcsize("4?i")
8
struct.calcsize("4?d")
16
第一个表达式中,"4?"占用4bytes,没问题,是4的整数倍,之后“i”占用4bytes,一共占用8bytes。
第二个表达式中,"4?"同样占用4bytes,没问题。但之后的“d”要求前面的字节数必须是8的整数倍,没办法,只能填上4个空字节来补位,这样就是4+4+8 = 16bytes。
到此,struct.calcsize()函数的计算方法应该已经很清晰了。
4、例子
下面我们给出一个实际文件操作例子演示下。
题目如下:
假设有一个org.dat文件,它以二进制方式存放一系列整数,每个整数占 4 个字节。
从第一个数开始,第一个整数和第二个整数构成一个坐标点,第二个整数和第三个整数构成一个坐标点……以此类推,可以认为数据文件中保存了许多坐标点数据。
问:
如果规定,处于第一象限的坐标点为有效点,请问数据文件中所有点的个数 n 为多少?有效点的个数 k 为多少?并输出这些有效点。
分析:
很显然,这道问题的重点是如何从文件中读取信息并转换为我们想要的数据。之后对数据的操作就非常简单了,就是小学数学题。
因为手中没有org.dat这个文件,那我们就自己模拟创建一个。
# 模拟org.dat的生成
import random
import struct
random.seed(12)
with open("org2012.dat", "wb") as f:
for i in range(20):
x = random.randint(-50, 50)
f.write(struct.pack("i", x))
基本的文件操作这里不再讲述。为了方便,我们产生20个在[-50, 50]内的随机数,并以二进制整数的形式写入文件,题目要求每个整数占4个字节,我们就令格式字符fmt = “i”。
现在我们就有了org.dat文件了。
首先的思路仍然是先以二进制形式读文件,但是这里需要注意的是,由于我们的数据是以4字节的整数为单位的,因此这里必须规定f.read()函数的参数为4,即f.read(4),每次读取4个字节。之后每读一个字节,都用unpack()函数进行解包。这里需要注意的是,要判断是否读到文件尾,否则会出错;如果读到文件尾了,那这个时候read()返回的数据就不能再用unpack()函数处理了,因此需要添加判断条件或者进行异常处理。
unpack()函数返回的结果是一个元组,如果不方便处理的话可以把里面的元素取出来(Python中的元组支持用[]下标索引)。
下面上代码:
# 读文件部分
with open("org2012.txt", "rb") as fp:
l = [] #记录数字
try:
while True:
temp = fp.read(4) #每次读取4个字节
if temp == "": #读到文件尾,退出循环
break
l.append(struct.unpack("i", temp)[0]) #按照规则,将4个二进制字节还原为原来的整数
except struct.error:
pass
# 数据处理部分
n = len(l) #所有点个数
print("所有点个数:{}".format(n))
coordinates = [] #记录坐标
for i in range(0, n, 2):
coordinates.append(tuple([l[i], l[i+1]]))
print("坐标如下:")
for x in coordinates:
print(x, end = " ")
print()
k = 0 #有效点个数
valid_coordinates = []
for coordinate in coordinates:
if coordinate[0] > 0 and coordinate[1] > 0:
valid_coordinates.append(coordinate)
k += 1
print("有效点个数:{}".format(k))
print("有效坐标如下:")
for x in valid_coordinates:
print(x, end = " ")
运行结果为:
所有点个数:20
坐标如下:
(10, -16) (34, 17) (35, -6) (-32, -2) (-49, -3) (11, -15) (32, 8) (38, 26) (-21, 21) (-50, 34)
有效点个数:3
有效坐标如下:
(34, 17) (32, 8) (38, 26)