Python – 存储二进制数据及序列化

本文是一系列介绍Python数据存储的文章之一。其他文章是:

上周我们已经看到了如何将数据存储到纯文本文件里,这样无论是任何编辑器或者其他程序都可以读取。我们还了解到,如果使用逗号来分割数据,那么文件需要遵循一个标准,然后就可以自动兼容其他应用程序,比如电子表格。这种方法的主要限制之一,就是如果数据本身就含有逗号的,那么文件就不再具有可读性。

本文将进一步讨论数据的编码。这将是我们能够理解为什么您使用Python所保存的内容,能够为普通的文本编辑器或者Web浏览器读取。我们还将了解到,如果我们能以正确的方式对数据进行编码,那么就可以节省磁盘上的空间。最后,我们还将仔细的讲解保存纯文本文件和二进制数据文件之间的区别。

保存文本文件究竟意味着什么

上周,您已经了解了保存文本文件的不同方法。最值得注意的一点是,这些文件可以用任何文本编辑器打开,您无需Python来读取这些数据。这表明,已经有一种允许程序之间相互共享文件的基本属性。

您可能已经听说过,计算机并不理解什么是字母或者颜色,他们只能理解1和0。这意味着,有一种方法,将此类信息转换成字母(数字,或者颜色)。从一种信息的表示形式转换到另一种的方法称之为编码。您可能已经听说过ASCII或者Unicode。这些就是指定了如何从一个字节流(也就是1和0)转换到您现在在屏幕上看到的字符的标准。

ASCII是美国信息交换标准代码(American Standard Code for Information Interchange)的缩写。它是关于如何将字节转换为字符(或者反之)的规范。如果您查看ASCII编码表,您会看到它指定了如何将从0到127的数字转换为字符。128项数字并不是随便选的,它代表了一个7位的二进制数的所有组合。您还可以看到扩展的ASCII表,其中字符的数量总和达到了255(也就是8位二进制)。

让我们做一个小测试。我们可以创建一个0到255(8位)之间的整数数组,然后将其存到一个文件中,然后比较这个文件和数组的大小。代码如下:

import sys
import numpy as np

x = np.linspace(0, 255, 256, dtype=np.uint8)

with open('AA_data.dat', 'w') as f:
    for data in x:
        f.write(str(data)+'\n')

数组中的每个元素的长度是8位(一个字节)。我们存储了256个元素,那么,文件的大小应该是256个字节。但是如果您检查一下,就会发现它比这个尺寸要大很多:大约在914个字节左右。二者的差异是不可忽视的,所以,这是从哪儿来的呢?

我们存储了256个元素,但是文件里的数字则要多很多。其中10个元素仅有1位,90个有2位,156个有3位。总共是658位数字,再加上256个换行符。如果把这些加起来恰好是914。这就是说,每个字符都单独占据了一个字节(8位)。如果你是在Windows下运行,还要额外考虑一点,因为换行符会占据两个字符而不是一个,因此你需要额外加上512个换行符空间,而不是256个。

如果你想写入1,那就是一个字节。但是如果你要存储10,那么就是2个字节。但此外您应该记住,在八位整型数的空间里,他们所占用的内存是一样多的。通过这个简单的例子,您会发现在保存数据的时候,需要考虑很多的小细节。

文本数据的不同编码

在上一节中,您可能已经意识到,ASCII码仅仅包含了一组特定的字符。如果您要写入其他语言的字符,比如西班牙语中的ñ,那么您就需要使用其他标准。这导致了无数种不同的编码,彼此之间的兼容性很差。如果您在使用Notepad++,您可以在编码中这个菜单项里看到很多选项。

当您打开一个文本文件的时候,程序需要将字节转换为字符,这个过程基本是在查表。如果您改动了这个表,那么也就改变了输出。如果您在使用类似Notepad++这样的文本编辑器,那么您可以看到能够指定文件的编码方式。在菜单上选择编码,然后选择字符集,您会发现大量的选项。如果试着选几个来玩一下,您可能会发现文本的内容改变了,尤其是如果其中有来自其他语言的特殊字符的时候。

如果是在网站上,那么这个问题就更糟糕了,因为来自不同国家的用户会希望采用不同的字符集。这就是一个最终替代标准出台的原因,称为Unicode。Unicode包含了ASCII码表并扩展到32位,这样可以制作一个能容纳几十亿字符的表格。Unicode包含了数千个古代和现代语言中的符号,以及您已经十分熟悉的Emoji表情。

如果您希望在保存文件的时候指定其编码方式,您可以这么做:

import codecs

data_to_save = 'Data to Save'
with codecs.open('AB_unicode.dat', 'w', 'utf-8') as f:
    f.write(data_to_save)

在上面的代码里,重要的部分是带有utf-8的那行。Unicode有不同的实现方式,而每种不同的实现方式对每个字符所占的位数都有不一样的规定。您可以在8,16和32之中选择。您当然也可以将编码方式改为ascii。作为练习,您可以试着以不同的方式保存,并比较他们所占用的空间。然后用文本编辑器打开您所保存的文件,看看是否仍然可以读出信息。

保存Numpy数组

上周我们已经看到,可以将numpy数组保存到任何编辑器都可以读取的文本文件里。这就是说,信息会被转化为ascii(或者Unicode)编码,然后写入一个文件。根据您所存储的单个字符位数,很容易计算需要多少存储空间。然而,Numpy另外提供了一种以二进制的形式来存储数据的方法。

我们之前所做的是将数字转化为它的字符形式,这样可以允许我们之后在屏幕上阅读。然而,有时候我们并不需要读这个数据,而只是希望程序能够加载回这些信息。因此,我们可以直接将字节存储到磁盘,而无需保存它们对应的字符串的形式。

让我们从创建一个数组开始,然后我们将其保存为numpy二进制和ascii两种格式来进行比较:

import numpy as np

a = np.linspace(0, 1000, 1024, dtype=np.uint8)

np.save('AC_binay', a)

with open('AC_ascii.dat', 'w') as f:
    for i in a:
        f.write(str(i)+'\n')

您会得到两个不同的文件,一个名为“AC_binary.npy”,另一个名为“AC_ascii.dat”。后者可以用任何一个文本编辑器打开,而如果打开前者,则会看到一个非常奇怪的文件。如果比较二者大小,您会发现二进制文件占用的空间较小。

首先,您应当注意到上面代码的一些奇怪之处。我们指定数组的类型是np.uint8,这就是说我们强制使用了8位整型变量。8位整型变量的上限是2^8-1,也就是255。此外,由于我们使用1024个元素来均分从0到1000的空间,因此每个元素都会是一个近似值。不管怎么说,这里的讨论仅仅是想让您开始思考不同的数据类型及其涵义而已。如果您检查ascii文件,您会注意到数字增加到255,然后又从0开始。

所以,我们现在有1024个数字,每个占据8位,也就是1字节。因此,这个数组将会占据1kB(1 kilobytes)的空间,但我们所保存的文件仍然大于这个尺寸(大约在1.12kB)。您可以自行计算ascii文件的体积,并且看看预测是否准确。现在让我们创建一个全部是1的数组:

import numpy as np

a = np.ones((1024), dtype=np.uint8)

np.save('AD_binay', a)

with open('AD_ascii.dat', 'w') as f:
    for i in a:
        f.write(str(i)+'\n')

首先要注意的是,ascii码文件现在比上一个例子的小得多。每个元素都保存下来都只有两个字符(1和换行符),而之前每行最多可能有4个字符。然而,numpy二进制文件的大小和之前完全一样。那么,如果仍将上面的代码重新运行一遍,仅仅是将类型改为np.uint16,会发生什么呢?

您会发现,ascii文件的大小不变,还是整整2kB(Windows下是3kB)。但是,numpy二进制格式会占据更多的空间,正好多出整整1kB。这个数组本身占据了2kB的内存,然后像之前一样,额外多出0.12kB。这已经给了我们一些提示,但您还是可以继续测试。将类型更改为np.uint32,然后您会发现ascii文件还是一样大小,但二进制格式的存储文件又变大了2KB。同样,您将4KB的数据放入了一个4.12KB的文件之中。

numpy所保存的那些额外的 .12KB 相当于我们在前面的文章中提到过的产生的标题。二进制文件也需要存储背景信息才可以被读取。您还应该注意到存储的并不“仅仅”是数字,其数据格式也被存储起来了。下次您读取文件的时候,您还是会得到8位,16位或者32位的变量。而ascii文件则不会带有这些信息。

从上面的例子看来,似乎存储ascii文件比存储二进制文件更有效率。让我们看看如果数组里不光是1会发生什么:

import numpy as np

a = np.linspace(0,65535,65536, dtype=np.uint16)
np.save('AE_binay', a)
with open('AE_ascii.dat', 'w') as f:
    for i in a:
        f.write(str(i)+'\n')

比较两个文件,试图弄懂为什么有这么大的差异。

Pickle模块介绍

到目前为止,我们已经讨论了如何将字符串或numpy数组保存到文件中。但是,Python允许您定义几种类型的数据结构,例如列表、字典、自定义对象等。您可以考虑如何将列表转换为一系列字符串,并使用相反的操作恢复变量。这是我们之前在将数组写入纯文本文件的时候介绍过的。

然而,这是非常麻烦的,因为非常容易受到微小变化的影响。例如,要保存纯数字列表,与保存混合数字和字符串的列表就不是一回事。幸运的是,Python附带了一个名为Pickle的模块,它允许我们保存几乎所有我们想要的东西。让我们先看一个例子,再来讨论它是如何工作的。

假设您有一个混合了数字和字符串的列表,并想把它们保存到一个文件中,您可以这样做:

import pickle

data = [1, 1.2, 'a', 'b']

with open('AF_custom.dat', 'wb') as f:
    pickle.dump(data, f)

如果您试着用一个文本编辑器打开AF_custom.dat文件的话,会看到一堆奇怪的字符。需要注意的是我们刚才是以wb方式打开这个文件的,这意味着我们在像之前一样写入文件,但是是以二进制方式打开文件的。这就是Python得以将字节流直接写入文件的原因。

如果您需要将数据加载回Python,可以这样做:

with open('AF_custom.dat', 'rb') as f:
    new_data = pickle.load(f)

print(new_data)

这里需要注意我们使用rb而不是之前的r来打开文件。然后您只需要将f的内容读入名为new_data的变量即可。

Python会将对象(在上面的例子中是一个列表)转化为一个字节流。这个过程称之为序列化(serialization)。负责序列化信息的算法是Python所特有的,因此它与其他的编程语言不兼容。在Python里,序列化一个对象称之为pickling,而反序列化一个对象则称为unpickling

Picklingnumpy数组

您可以使用Pickle模块保存其他各种类型的变量。例如,您可以用它来保存一个numpy数组:我们来比较一下使用numpysave和使用Pickle会有什么不同。

import numpy as np
import pickle

data = np.linspace(0, 1023, 1000, dtype=np.uint8)

np.save('AG_numpy', data)

with open('AG_pickle.dat', 'wb') as f:
    pickle.dump(data, f)

和之前的例子一样,numpy文件的大小是精确的1128kB。1000是数据本身,另外128则是额外信息。Pickle产生的文件大小是1159kB,这相当不错,尤其考虑到这是一个通用的程序,而不是专为numpy设计的。

要读回这个文件,仍然和之前一样:

with open('AG_pickle.dat', 'rb') as f:
    new_data = pickle.load(f)

print(new_data)

这时检查数据,会发现它就是一个numpy数组,如果在未安装numpy的环境中运行这段代码,会引发以下错误:

Traceback (most recent call last):
  File "AG_pickle_numpy.py", line 14, in <module>
    new_data = pickle.load(f)
ModuleNotFoundError: No module named 'numpy'

所以,您会发现pickle会在后台悄悄做很多事情,比如试着导入numpy模块。

Pickling 函数

为了展示出Pickle模块的灵活性,接下来您会看到如何存储函数。您很可能已经听说过,在Python中万物皆对象这句话,而Pickle实际上也是序列化对象的一种方式。因此,存储什么其实并不重要。您可以设计这样一个函数:

def my_function(var):
    new_str = '='*len(var)
    print(new_str+'\n'+var+'\n'+new_str)

my_function('Testing')

这是一个简单的函数的例子。它将文本用=字符围绕起来。存储这个函数的过程和存储别的任何对象毫无二致:

import pickle

with open('AH_pickle_function.dat', 'wb') as f:
    pickle.dump(my_function, f)

要读回这个函数并使用的话:

with open('AH_pickle_function.dat', 'rb') as f:
    new_function = pickle.load(f)

new_function('New Test')

Pickle 模块的限制

要让Pickle正常工作,就需要有要Pickle的对象的定义。在上面的例子中,您已经看到,需要安装numpy才能正确的解序列化numpy数组。然而,如果要试图要解序列化一个不是在本文件里定义,而是在其他文件中定义的函数,就会引发以下错误:

Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
AttributeError: Can't get attribute 'my_function' on <module '__main__' (built-in)>

但如果确实需要解序列化一个在其他文件中定义的函数(大多数情况都可能是这样),您可以执行以下操作:

import pickle
from AH_pickle_function import my_function

with open('AH_pickle_function.dat', 'rb') as f:
    new_function = pickle.load(f)

现在您当然会怀疑这么做有什么用呢?如果已经导入了my_function,当然不必从序列化的文件中再将其读取出来。确实如此。存储一个函数或者一个类,并没有太大的用处,因为不管怎样,都还是需要其明确的定义。但如果您想要存储一个类的实例的时候,那就会很不一样了。比如,我们来定义一个类,会存储该类的实例被创建的时间:

import pickle
from time import time
from datetime import datetime

class MyClass:
    def __init__(self):
        self.init_time = time()

    def __str__(self):
        dt = datetime.fromtimestamp(self.init_time)
        return 'MyClass created at {:%H:%M on %m-%d-%Y}'.\
            format(dt)

my_class = MyClass()
print(my_class)

with open('AI_pickle_object.dat', 'wb') as f:
    pickle.dump(my_class, f)

如果执行上述代码,会得到一个存储有其创建时间的对象。如果打印这个对象,则可以看到日期和时间有着良好的格式。另外需要注意的是,存储到序列化文件中的是my_class而不是Myclass。这意味着保存的是类的一个实例,其中包含有已经定义好的属性。

现在如果想从第二个文件中读取出已经保存的内容,那么应该导入Myclass这个类,但是实例才是所保存的东西。

import pickle
from AI_pickle_object import MyClass


with open('AI_pickle_object.dat', 'rb') as f:
    new_class = pickle.load(f)

print(new_class)

注意我们并没有导入time或者datetime模块,只是导入了pickle用来读取类和对应的对象。如果您希望保存一个对象的某个特定状态以便之后可以继续工作,那么Pickle是一个很不错的工具。

Pickle 的风险

如果您经常四处逛逛的话,一定已经见到过不少人警告说Pickle用起来并不安全。主要原因是,在解序列化的过程中,任何代码都可以被机器执行。如果您是唯一使用这些文件的人,或者对给你文件的人有绝对的信任,那么就没有什么问题。但如果您是在构建一个线上服务,那么随意对用户上传的数据进行解序列化就可能会产生一些后果。

当Pickle运行的时候,会寻找类中的一个名为__reduce__的特殊方法,这个方法指定了对象如何序列化和反序列化。无需输入太多细节,您可以指定在反序列化时执行的可调用函数。在上面的例子中,可以为Myclass添加一个额外的方法:

class MyClass:
    def __init__(self):
        self.init_time = time()

    def __str__(self):
        dt = datetime.fromtimestamp(self.init_time)
        return 'MyClass created at {:%H:%M:%S on %m-%d-%Y}'.\
            format(dt)

    def __reduce__(self):
        return (os.system, ('ls',))

再次运行代码以保存序列化之后的文件。如果您运行该文件以加载序列化之后的对象,您会发现被执行的这段代码所在的文件夹下所有的的内容都会显示出来。Windows 用户可能看不到这样的情况,因为取决于您使用的是Power Shell还是CMD,命令ls可能并未被定义。

这只是一个相当简单的例子。您大可将其中的ls命令替换为删除某个文件, 打开某个外部的攻击链接,或者将所有的文件发送至一个服务器,等等。您会发现您打开了大门,允许其他人在您的电脑上执行命令,最终会发生一些非常糟糕的事情。

然而,对绝大多数终端用户而言,Pickle所带来的安全风险是非常低的。最重要的是事情,就是相信您的Pickle文件的来源。如果是您自己或者同事什么的,那大概不会有什么问题。但如果Pickle文件的来源不可靠,那您必须意识到其中的风险。

您大概会奇怪为什么Python要允许这样的安全风险存在。答案是,允许定义如何反序列化一个对象,您可以将存储数据的效率提高很多很多。其思想是,您可以在不加载其包含的所有信息的情况下,定义如何重建一个对象。对于前面的numpy数组的例子而言,假设您定义了一个1024×1024个元素的组成的矩阵,全部是1(或者0)。您可以存储其中的每个值,这样将会占用大量内存。或者,您可以直接让Python运行numpy并创建这个矩阵,这样就占不了多少空间了(也就一行代码)。

拥有控制权永远好过没有。如果想要确保不会有糟糕的后果,那您必须去寻找其他的序列化数据的方法。

注意。

如果您想要在上面的例子中使用Pickle,那么最好考虑使用cPickle来代替Pickle,二者算法一致,但是cPickle是直接用C语言写的,因此会快很多。

使用 JSON 来序列化

序列化背后的主要思想是将对象转换为其他易于存储或传输的形式。Pickle是一种非常方便的方法,但是在安全性方面有一些限制。此外,Pickle的结果不可读,因此它使得获取文件内容变得更加困难。

JavaScript Object Notation,简称 JSON,已经成为网络服务中十分流行的标准数据交换格式。它是一种定义如何构造便于之后转换为变量的字符串格式的的方法。我们先来看一个带有字典的简单的例子:

import json

data = {
    'first': [0, 1, 2, 3],
    'second': 'A sample string'
}

with open('AK_json.dat', 'w') as f:
    json.dump(data, f)

如果打开该文件,您将注意到结果是一个文本文件,可以通过文本编辑器轻松读取。此外,只消看一眼文件内容,您大概就可以理解这些数据。您还可以定义更复杂的数据结构,例如列表和字典的组合,等等。要读回一个json文件,可以执行以下代码:

with open('AK_json.dat', 'r') as f:
    new_data = json.load(f)

JSON非常方便,因为它能以一种可以与其他编程语言共享数据,或者通过网络传输并存为文件之后易于读取的方式来构建信息。然而,如果您试图保存一个类的实例,您将会得到以下错误提示信息:

TypeError: Object of type 'MyClass' is not JSON serializable

JSON也没法利用现成的numpy数组。

将JSON和Pickle合并使用

如您所见,JSON是将文本写入文件的一种方式,其结构使其易于加载信息并将其转换为列表、字典等。另一方面,Pickle将对象转换为字节。如果能将这两者结合起来将字节写入文本文件就好了。如果您希望在保存复杂结构的同时,又能通过肉眼查看文件的各个部分,那么将纯文本和字节结合起来可能是个好主意。

我们的要求其实并不复杂。我们只是需要一种将字节转换成ASCII字符串的方法。如果您还记得这个系列文章开头的讨论的话,有一个称为ASCII的标准,它将字节转换为您可以阅读的字符。当互联网开始流行时,人们需要传递的不再是纯文本了。因此,出现了一个新的标准,利用这种标准,您可以将字节转换为字符。这被称为Base64,大多数编程语言都支持它,包括Python。

下面是一个例子,我们将生成一个numpy数组,我们将会使用pickle将其序列化,然后我们建立一个字典,包含有这个数组和当前的时间。代码如下:

import pickle
import json
import numpy as np
import time
import base64

np_array = np.ones((1000,2), dtype=np.uint8)
array_bytes = pickle.dumps(np_array)
data = {
    'array': base64.b64encode(array_bytes).decode('ascii'),
    'time': time.time()
}

with open('AL_json_numpy.dat', 'w') as f:
    json.dump(data, f)

注意

在上述例子里,我们使用了pickle.dumps而不是pickle.dump,这样会返回信息,而不是将其写入一个文件。

您可以查看一下这个文件。您会发现可以读懂其中一些部分,比如这个词“array”以及其创建的时间。但是,数组本身却是一串没有什么意义的字符。如果想要将数据读取回来,您需要以相反的顺序来重复这些步骤:

import pickle
import base64
import json

with open('AL_json_numpy.dat', 'r') as f:
    data = json.load(f)

array_bytes = base64.b64decode(data['array'])

np_array = pickle.loads(array_bytes)
print(data['time'])
print(np_array)
print(type(np_array))

第一个步骤是以只读模式打开文件。然后,将用base64编码过的序列化块反序列化。输出就是序列化过的数组,接下来只需要反序列化。最后您只需将其打印出来,看看是否已经有效的恢复了numpy数组。

此刻,您应该问自己两个问题。为什么不直接使用Pickle序列化data这个字典,而要费劲将其使用Pickle模块序列化,再以base64编码,最后还要用json将其序列化?而且,为什么我们在已经使用Pickle序列化数组之后,要将其以base64编码,而不直接将Pickle的输出写入文件呢?

首先,如果要使用其他程序来查看数据,那么这些麻烦还是值得的。将含有额外信息的文件存储为易读的形式,可以很快看出来这是不是你需要读取的文件。举例来说,如果你用一个文本编辑器打开这个文件,发现这并不是你感兴趣的数据,那么就可以略过这个文件继续。

第二个问题的答案就有点深了。记得吗,当您写入一个纯文本文件的时候,其实已经暗含了一种编码格式。如今最通用的格式是UTF-8。由于这种编码限定你只能使用相当有限的字符集,这在很多方面限制了您将字节写入磁盘的方式。Base64则通过只使用最有限的字符集解决了这个问题。

然而,您必须记住,base64是很久之前开发出来用于通过网络传输数据的。这使得base64并未达到它的最大传输速度,内存占用也不够优化。如今,得益于Unicode,我们不必再受限于ascii规范。然而,如果您想要您的代码可以兼容不同的系统,那么坚持标准会是一种良好的工程实践。

其他序列化选项

我们已经看到了如何使用Pickle和JSON来序列化对象,然而,这并非仅有的两个选择。毫无以为,他们是最为流行的方法,但是您也可能会面临打开其他程序所生成的文件的挑战。例如,LabView一般使用XML而不是JSON来存储数据。

JSON可以很容易的转换成Python中的变量,XML就相对麻烦一点。通常,XML文件来自于一个外部源,例如某个网站或者其他程序。要读取这些文件中的数据,您需要依赖ElementTree,点击这个链接可以通过官方文档查看它的用法。

另一种可能是使用YAML。这是一种简单的标记语言,像Python一样,它也使用空格来分割内容块。YAML的优点是易于输入。例如,假想您在使用文本文件作为您的程序的输入。如果您使用了很好的缩进,那么文件就容易解析多了。一个YAML文件一般长这样:

data:
  creation_date: 2018-08-08
  values: [1, 2, 3, 4]

要读取此类文件,只需要通过pip安装一个叫做PyYAML的包:

pip install pyyaml

读取文件的代码长这样:

import yaml

with open('AM_example.yml', 'r') as f:
    data = yaml.load(f)

print(data)

您也可以写一个yaml文件:

import yaml
from time import time

data = {
    'values': [1, 2, 3, 4, 5],
    'creation_date': time(),
}

with open('AM_data.yml', 'w') as f:
    yaml.dump(data, f)

本文并不打算讨论YAML,但您可以从网上获取很多相关信息。YAML还没有成为标准,但正在获得越来越多的关注。在YAML文件中编写配置文件感觉非常自然。与XML相比,打字要简洁很多,而和JSON相比,看起来也更有条理,起码对我来说是这样。

结论

在本文中,我们讨论了如何序列化对象,以及如何将其存储在硬盘里。我们也开始讨论了什么是编码,以及考虑如何将数据转换为字节或反之。这就开启了后续理解Pickle工作原理以及如何将数据保存到磁盘的大门。

请记住Pickle并不完美,您应牢记其用途的限制,尤其当您需要处理用户提交的文件的时候,比如站点服务器上的文件。另一方面,如果您只是用它来为自己存储数据,并不失为一种高效的方法。

我们还讨论了如何使用JSON,这是一种非常流行的web技术工具。不过,JSON的限制是必须将数据存储为文本文件,从而限制了其功能。幸运的是,结合Picklebase64,您可以将字节转换为ascii字符串,并将其保存在易于读取的元数据旁边。

尽管本文就如何以不同的格式存储数据进行了非常深入的讨论,但这个主题还远未完成。请继续关注有关如何使用Python保存数据的更多文章。

发表评论

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据