IO编程

什么是IO

IO在计算机中指输入和输出(Input/Output)。由于程序运行时,数据是在内存中驻留,并由CPU这个超快的计算核心来进行处理的(处理时会把数据从内存载入到CPU的高速缓存中),而涉及到数据交换的操作,比如磁盘读写、网络传输等的时候,就需要使用IO接口来协调了。 比如你打开浏览器,访问新浪首页,浏览器这个程序就需要通过网络IO获取新浪的网页。浏览器首先会发送数据给新浪服务器,告诉它我想要首页的HTML,这个动作是往外发数据,叫Output,随后新浪服务器把网页发过来,这个动作是从外面接收数据,叫Input。所以,通常,程序完成IO操作会有Input和Output两个数据流。当然也有只用一个的情况,比如,从磁盘读取文件到内存,就只有Input操作,反过来,把数据写到磁盘文件里,就只是一个Output操作。

IO编程中,流(Stream)是一个很重要的概念,可以把流想象成一个水管,数据就是水管里的水,但是只能单向流动。Input Stream就是数据从外面(磁盘、网络)流进内存,Output Stream就是数据从内存流到外面去。对于浏览网页来说,浏览器程序和新浪服务器之间至少需要建立两根水管,才可以既能发数据,又能收数据。

由于CPU和内存的速度远远高于外设的速度,所以,在IO编程中,就存在速度严重不匹配的问题。举个例子来说,比如要把100M的数据写入磁盘,CPU输出100M的数据只需要0.01秒,可是磁盘要接收这100M数据可能需要10秒,怎么办呢?有两种办法:

  • 第一种方法是让CPU等待,也就是程序暂停执行后续代码,等100M的数据在10秒后写入磁盘,再接着往下执行,这种模式称为同步IO

  • 第二种方法是CPU不等待,只是告诉磁盘,“您老慢慢写,不着急,我接着干别的事去了”,于是,后续代码可以继续执行,这种模式称为异步IO

同步和异步的区别就在于是否等待IO执行的结果。好比你去麦当劳点餐,你说“来个汉堡”,服务员告诉你,对不起,汉堡要现做,需要等5分钟,于是你站在收银台前面等了5分钟,拿到汉堡再去逛商场,这是同步IO。

你说“来个汉堡”,服务员告诉你,汉堡需要等5分钟,你可以先去逛商场,等做好了,我们再通知你,这样你可以立刻去干别的事情(逛商场),这是异步IO。

很明显,使用异步IO来编写程序性能会远远高于同步IO,但是异步IO的缺点是编程模型复杂。想想看,你得知道什么时候通知你“汉堡做好了”,而通知你的方法也各不相同。如果是服务员亲自跑过来找到你,这是回调模式,如果服务员发短信通知你,你就得不停地检查手机,这是轮询模式。总之,异步IO的复杂度远远高于同步IO。

操作IO的能力都是由操作系统提供的编程语言所做的只是把操作系统提供的低级C接口封装起来方便使用,Python也不例外。后面的小节中会详细讨论Python的IO编程接口。

注意,本章的IO编程都是同步模式,异步IO由于复杂度太高,后续涉及到服务器端程序开发时会再作讨论。



文件读写

读写文件是最常见的IO操作。Python内置了读写文件的函数,用法和C是兼容的。

读写文件前,我们先必须了解一下,在磁盘上读写文件的功能都是由操作系统提供的,现代操作系统不允许普通的程序直接操作磁盘,所以,读写文件就是请求操作系统打开一个文件对象(通常称为文件描述符),然后,通过操作系统提供的接口从这个文件对象中读取数据(读文件),或者把数据写入这个文件对象(写文件)。


读文件

要以读文件的模式打开一个文件对象,可以使用Python内置的 open() 函数,传入文件名(如果文件和代码文件在相同文件夹下就可以省略路径)和标示符 'r'

1
>>> f = open('/Users/michael/test.txt', 'r')

标示符 'r' 表示读,这样,我们就成功地打开了一个文件。

如果文件不存在,open() 函数就会抛出一个 IOError 的错误,并且给出错误码和详细的信息告诉你文件不存在:

1
2
3
4
>>> f=open('/Users/michael/notfound.txt', 'r')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
FileNotFoundError: [Errno 2] No such file or directory: '/Users/michael/notfound.txt'

如果文件打开成功,我们就可以调用 read() 方法来一次读取文件的全部内容,Python会把为文件的内容读到内存,返回的是一个 str 对象:

1
2
>>> f.read()
'Hello, world!'

读取完毕后,如果不需要继续操作文件对象,我们就应当调用 close() 方法来关闭它。因为文件对象会占用操作系统的资源,并且操作系统同一时间能打开的文件数量也是有限的

1
>>> f.close()

由于读写文件都有可能产生 IOError,一旦出错,后面的 f.close() 就不会调用。所以,为了保证无论是否出错都能正确地关闭文件,我们可以使用 try ... finally 来实现:

1
2
3
4
5
6
try:
f = open('/path/to/file', 'r')
print(f.read())
finally:
if f:
f.close()

但是每次都这么写实在太繁琐,所以,Python引入了 with 语句来自动帮我们调用 close() 方法((在上一章中有 with 语句使用及原理的介绍)):

1
2
with open('/path/to/file', 'r') as f:
print(f.read())

这和前面的 try ... finally 实现的效果是一样的,但是代码更简洁,并且我们不必调用 f.close() 方法。

调用 read() 方法可以一次性读取文件的全部内容。但如果文件有10G,内存就爆了,所以,为了保险起见,我们可以多次调用 read(size) 方法,每次最多读取size个字节的内容。

但是有时候文件不一定有严格的格式,比方说读取一篇文章,这时按字节读取就不太合适了。但我们可以调用 readline() 方法,readline() 方法每次读取文件的一行内容。而调用 readlines() 方法则会一次读取文件的所有内容并按行返回一个 list 对象。我们可以:

1
2
for line in f.readlines():
print(line.strip()) # 把末尾的换行符'\n'删掉再打印

写文件

写文件和读文件是一样的,唯一区别是调用 open()函数时,传入标识符 'w' 或者 'wb' 表示写文本文件或写二进制文件:

1
2
3
>>> f = open('/Users/michael/test.txt', 'w')
>>> f.write('Hello, world!')
>>> f.close()

你可以多次调用 write() 来写入文件,但是最后一定要调用 f.close() 来关闭文件。当我们写文件时,操作系统往往不会立刻把数据写入磁盘,而是放在内存中缓存起来,空闲的时候再慢慢写入。只有调用 close() 方法时,操作系统才会保证把没有写入的数据全部写入磁盘。忘记 close() 的后果是数据可能只有一部分写到了磁盘,剩下的丢失了。为了避免这样的情况发生,类似上一节所介绍的,我们可以使用 with 语句自动管理上下文:

1
2
with open('/Users/michael/test.txt', 'w') as f:
f.write('Hello, world!')

如果要写入特定编码的文本文件,还可以给 open() 函数传入 encoding 参数,将要写入的字符串自动转换成指定编码。


file-like Object

在Python中,除了文件对象之外,内存中的字节流,网络流,自定义流等等,拥有 read() 方法的对象统称为 file-like Objectfile-like Object 不需要继承自特定的类,只要有 read() 方法就行(文件对象的其他方法不一定都需要实现,可以看看官方说明),这得益于Python鸭子类型的实现。StringIO 就是在内存中创建的 file-like Object,常用作临时缓冲。


二进制文件

前面讲的默认都是读取文本文件,并且是UTF-8编码的文本文件。要读取二进制文件,比如图片、视频等等,用 'rb' 模式打开文件即可:

1
2
3
>>> f = open('/Users/michael/test.jpg', 'rb')
>>> f.read()
b'\xff\xd8\xff\xe1\x00\x18Exif\x00\x00...' # 十六进制表示的字节

字符编码

要读取非UTF-8编码的文本文件,可以给 open() 函数传入 encoding 参数,例如,读取GBK编码的文件:

1
2
3
>>> f = open('/Users/michael/gbk.txt', 'r', encoding='gbk')
>>> f.read()
'测试'

遇到有些编码不规范的文件,你可能会遇到 UnicodeDecodeError,因为在文本文件中可能夹杂了一些非法编码的字符。遇到这种情况,open() 函数还接收一个 errors 参数,表示如果遇到编码错误后如何处理。最简单的方式是直接忽略:

1
>>> f = open('/Users/michael/gbk.txt', 'r', encoding='gbk', errors='ignore')

小结

在Python中,文件读写是通过 open() 函数打开的文件对象完成的。使用 with 语句操作文件IO是个好习惯。



StringIO和BytesIO

StringIO

很多时候,数据读写不一定是对文件进行的,我们也可以在内存中进行读写操作。

StringIO 顾名思义就是在内存中读写 str

要把 str 写入 StringIO,我们需要先创建一个 StringIO 对象,然后,像文件一样写入即可:

1
2
3
4
5
6
7
8
9
10
>>> from io import StringIO
>>> f = StringIO()
>>> f.write('hello')
5
>>> f.write(' ')
1
>>> f.write('world!')
6
>>> print(f.getvalue())
hello world!

getvalue() 方法用于获得IO流中的全部内容

读取 StringIO 的方法也和读文件类似:

1
2
3
4
5
6
7
8
9
10
11
>>> from io import StringIO
>>> f = StringIO('Hello!\nHi!\nGoodbye!') # 用一个str初始化StringIO
>>> while True:
... s = f.readline()
... if s == '': # 读取完毕,跳出循环
... break
... print(s.strip()) # 去掉当前行首尾的空格再打印
...
Hello!
Hi!
Goodbye!

BytesIO

StringIO 操作的只能是 str,如果要操作二进制数据,就需要使用 BytesIO

BytesIO 实现了在内存中读写 bytes,我们创建一个 BytesIO,然后写入一些 bytes

1
2
3
4
5
6
>>> from io import BytesIO
>>> f = BytesIO()
>>> f.write('中文'.encode('utf-8'))
6
>>> print(f.getvalue())
b'\xe4\xb8\xad\xe6\x96\x87'

请注意,写入的不是 str,而是经过UTF-8编码的 bytes

StringIO 类似,读取 BytesIO

1
2
3
4
>>> from io import BytesIO
>>> f = BytesIO(b'\xe4\xb8\xad\xe6\x96\x87') # 用一个bytes初始化BytesIO
>>> f.read()
b'\xe4\xb8\xad\xe6\x96\x87'

为什么使用StringIO和BytesIO

这个问题在Stackoverflow上有回答,为什么我们不直接使用 strbytes,而要这么别扭地在内存中使用 StringIOBytesIO 呢?其实呀,主要是因为 StringIOBytesIO 都是 file-like Object,可以像文件对象那样使用,当某些库的函数支持文件对象时,我们可以传入 StringIOBytesIO,也能使用,这一点 strbytes 是没法做到的。


读写IO需要注意的地方

StringIO 为例,前面我们将到读取 StringIO 时,是先使用字符串进行初始化,然后再读取

1
2
3
>>> f = StringIO('Hello!\nHi!\nGoodbye!') # 用一个str初始化StringIO
>>> f.readlines()
['Hello!\n', 'Hi!\n', 'Goodbye!']

但如果我们没有进行初始化,而是对一个空的 StringIO 进行写入,然后再读取呢?这时就会像下面这样:

1
2
3
4
5
6
7
8
9
>>> f = StringIO()
>>> f.write('Hello!\n')
7
>>> f.write('Hi!\n')
4
>>> f.write('Goodbye!')
8
>>> f.readlines()
[]

我们发现此时读取不到写入的字符串了,这是为什么呢?其实呀,这时因为当前所处流的位置(Stream Position)在末尾,所以读取不到东西了。那怎么知道当前处在流的什么位置呢?我们可以使用 tell() 方法。对比一下:

使用字符串初始化 StringIO

1
2
3
>>> f = StringIO('Hello!\nHi!\nGoodbye!')
>>> f.tell()
0

写入 StringIO

1
2
3
4
5
6
7
8
9
>>> f = StringIO()
>>> f.write('Hello!\n')
7
>>> f.write('Hi!\n')
4
>>> f.write('Goodbye!')
8
>>> f.tell()
19

可以发现,使用字符串初始化时,位置会保持在流的开头,而使用 write() 方法对流进行写入操作后,位置会移动到写入结束的地方。那有没有办法在写入以后进行读取呢?有!可以使用前面提到的 getvalue() 方法读取IO流中的全部内容,另外也可以使用 seek() 方法回到前面的某一位置,然后读取该位置后的内容:

1
2
3
4
5
6
>>> f.tell()
19
>>> f.seek(0) # 回到流的开头位置
0
>>> f.tell()
0

有时候呀,我们可能会在初始化一个 StringIO 之后,想要对其进行写入操作,这时会发生一个问题:

1
2
3
4
5
6
7
>>> f = StringIO('Hello!')
>>> f.getvalue()
'Hello!'
>>> f.write('Hi!')
3
>>> f.getvalue()
'Hi!lo!'

可以看到初始化的内容被写入的内容覆盖了,这显然不是我们所希望的。为什么会这样呢?其实呀,跟前面说的问题是一样的,举一反三,都是因为Stream Position引起的。初始化一个 StringIO 后,位置在流的开头,此时写入就会从流的开头写入,而不是像我们所希望的那样从流的末尾写入,稍微改动一下就好了:

1
2
3
4
5
6
7
>>> f = StringIO('Hello!')
>>> f.seek(0, 2) # 移动到流的末尾
6
>>> f.write('Hi!')
3
>>> f.getvalue()
'Hello!Hi!'

除了移动到流的末尾,也能移动到某个位置,看看 seek() 方法的描述:

1
2
3
4
5
6
7
8
9
10
Help on built-in function seek:

seek(pos, whence=0, /) method of _io.StringIO instance
Change stream position.

Seek to character offset pos relative to position indicated by whence:
0 Start of stream (the default). pos should be >= 0;
1 Current position - pos must be 0;
2 End of stream - pos must be 0.
Returns the new absolute position.

可以看到 seek() 方法有必选参数 pos 和 可选参数 whence,前者是移动多少,后者是从哪里开始移动。whence 默认为0,也即默认从流的开头移动 pos 个位置。


小结

StringIOBytesIO 是在内存中操作 strbytes 的方法,和读写文件具有一致的接口。



操作目录和文件

简述

在命令行下,我们可以通过输入操作系统提供的各种命令,比如dir、cp等,来操作目录和文件。这些命令的本质其实就是简单地调用了操作系统提供的接口函数

那如果想在Python程序中操作目录和文件该怎么办呢?Python内置的 os 模块同样给与我们调用操作系统提供的接口函数的能力。

打开Python交互式命令行,首先看看如何使用 os 模块的基本功能:

1
2
3
>>> import os
>>> os.name
'posix'

Linux、Unix和Mac OS X系统返回的是 posix,Windows系统返回的则是 nt

要获取详细的系统信息,可以调用 uname() 函数:

1
2
>>> os.uname()
posix.uname_result(sysname='Darwin', nodename='MichaelMacPro.local', release='14.3.0', version='Darwin Kernel Version 14.3.0: Mon Mar 23 11:59:05 PDT 2015; root:xnu-2782.20.48~5/RELEASE_X86_64', machine='x86_64')

注意,uname() 函数在Windows系统上不提供,也就是说,os 模块的能否使用某些函数取决于使用者的操作系统


环境变量

在操作系统中定义的环境变量,全部保存在 os.environ 变量中。我们可以直接查看操作系统的所有环境变量:

1
2
>>> os.environ
environ({'VERSIONER_PYTHON_PREFER_32_BIT': 'no', 'TERM_PROGRAM_VERSION': '326', 'LOGNAME': 'michael', 'USER': 'michael', 'PATH': '/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/X11/bin:/usr/local/mysql/bin', ...})

要获取某个环境变量的值,可以调用使用 os.environ.get('key') 的方式:

1
2
3
4
>>> os.environ.get('PATH')
'/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/X11/bin:/usr/local/mysql/bin'
>>> os.environ.get('x', 'default')
'default'

传入某个环境变量的名称,得到对应的路径。除此之外还可以传入一个字符串作为默认路径(没有可返回的路径时会返回默认路径)。


操作目录和文件

除了前面的 os 模块中,操作目录和文件的函数还有一部分放在 os.path 模块中。比方说用于生成绝对路径的 abspath() 函数:

1
2
3
4
>>> os.path.abspath('.') # 点符代表当前工作路径
'F:\\Python35'
>>> os.path.abspath('Tools\\demos')
'F:\\Python35\\Tools\\demos'

05模块中归纳过文件搜索路径的一些知识,当我们在程序中需要用到某个文件时,可以使用两种方式来让程序查找到这个文件:

  • 一是使用绝对路径,也即完整的路径;
  • 二是使用相对路径(相对当前工作路径而言的路径),并且可以使用点符 . 来替代当前工作路径。

注意,使用相对路径时是可以不使用点符的,所以上面代码中,为 Tools\\demos 生成绝对路径也同样可行。

接下来我们试试创建目录和删除目录:

1
2
3
4
5
6
7
# 在某个目录下创建一个新目录,首先生成新目录的完整路径:
>>> os.path.join('/Users/michael', 'testdir')
'/Users/michael/testdir'
# 然后创建一个目录:
>>> os.mkdir('/Users/michael/testdir')
# 删掉一个目录:
>>> os.rmdir('/Users/michael/testdir')

把两个路径合成一个时,不要直接拼字符串,而要通过 os.path.join() 函数,这样可以正确处理不同操作系统的路径分隔符。在Linux/Unix/Mac下,os.path.join('part1','part2') 返回这样的字符串:

1
part-1/part-2

而Windows下会返回这样的字符串:

1
part-1\part-2

同样的道理,要拆分路径时,也不要直接去拆字符串,而要通过 os.path.split() 函数,这样可以把一个路径拆分为两部分,后一部分总是最后级别的目录或文件名

1
2
>>> os.path.split('/Users/michael/testdir/file.txt')
('/Users/michael/testdir', 'file.txt')

os.path.splitext() 函数可以用来获取文件扩展名,很多时候非常方便:

1
2
>>> os.path.splitext('/path/to/file.txt')
('/path/to/file', '.txt')

这些合并、拆分路径的函数并不要求目录和文件真实存在,它们只是对字符串进行操作。

文件操作使用下面的函数。假定当前目录下有一个 test.txt 文件:

1
2
3
4
# 对文件重命名:
>>> os.rename('test.txt', 'test.py')
# 删掉文件:
>>> os.remove('test.py')

但是 os 模块中不存在复制文件的函数!原因是复制文件并非是由操作系统提供的系统调用。理论上讲,我们通过上一节的读写文件可以完成文件复制,只不过要多写很多代码。

幸运的是 shutil 模块提供了 copyfile() 的函数,你还可以在 shutil 模块中找到很多实用函数,它们可以看做是对 os 模块的补充。

最后看看如何利用Python的特性来过滤文件。比如我们要列出当前目录下的所有目录,只需要一行代码:

1
2
>>> [x for x in os.listdir('.') if os.path.isdir(x)]
['.lein', '.local', '.m2', '.npm', '.ssh', '.Trash', '.vim', 'Applications', 'Desktop', ...]

要列出所有的 .py 文件,也只需一行代码:

1
2
>>> [x for x in os.listdir('.') if os.path.isfile(x) and os.path.splitext(x)[1]=='.py']
['apis.py', 'config.py', 'models.py', 'pymonitor.py', 'test_db.py', 'urls.py', 'wsgiapp.py']

是不是非常简洁?


小结

Python的 os 模块封装了操作系统的目录和文件操作,要注意这些函数有的在 os 模块中,有的在 os.path 模块中。

练习

习题1

利用 os 模块编写一个能实现 ls -l 输出的程序。

先看看 ls -l 做的是什么:

1
2
3
4
5
ubuntu@ubuntu:~/HumanFaceRecognitionWithNN$ ls -l
total 344
-rw-rw-r-- 1 ubuntu ubuntu 10301 Dec 14 22:28 face_recognition.py
drwxrwxr-x 3 ubuntu ubuntu 4096 Dec 21 15:50 test
-rw-rw-r-- 1 ubuntu ubuntu 328506 Dec 10 15:39 yaleB_face_dataset.mat

注意这是在Linux上执行的,我们想查看当前路径下有什么文件和文件夹可以使用 ls 或者 dir 命令,而如果我们想了解更详细的信息则可以用 ls -l 或者dir -l 命令。

这里稍微解析一下返回的信息吧,以下面这一条为例:

field1 field2 field3 field4 field5 field6 field7 field8 field9 field10
- rw- rw- r-- 1 ubuntu ubuntu 10301 Dec 14 22:28 face_recognition.py
  • field1 是 File type flag标识文件类型,如果是 - 则表明是普通文件,是 d 则表明是一个目录;
  • field2、field3、field4 依次是拥有者、拥有者所在的用户组以及其他用户对该文件/文件夹的操作权限r 表示可读,w 表示可写, x 表示可执行。
  • field5 是所含链接数,如果该项是一个文件,则链接数为1;如果该项是一个目录,则一般为该目录下子文件夹数+2。为什么呢?因为当前目录(该项的父目录)有一条指向该项的链接,而对文件夹来说,除了父目录的链接之外,它本身还有一条 . 链接指向自身,并且它的子目录都有一条 .. 链接指向它;
  • field6 是拥有者的名字
  • field7 是拥有者所在的用户组
  • field8 是该项的大小(多少bytes);
  • field9 是最后修改该项的日期和时间
  • field10 是该项的名字

我们注意到,除了每一项的详细信息之外,最前面还有一行输出 total 344,这个344是什么呢?它指的是当前目录所有文件和文件夹所使用的块(block)的数目,块是一个操作系统的概念,这里不详细展开。如果我们想知道当前目录下每一项所使用的块的数目,可以使用 ls -s命令:

1
2
3
ubuntu@VM-173-69-ubuntu:~/HumanFaceRecognitionWithNN$ ls -s
total 344
12 face_recognition.py 4 test 328 yaleB_face_dataset.mat

加起来总数正是 344

以上内容参考了以下几个链接:

题目要求实现Python版的 ls -l,理论上应该是可行的,但上面的内容只涉及到 osos.path 模块中很少的函数,其他的还有待发掘。我暂时没有时间去琢磨,所以先略过这一题。

习题2

编写一个程序,能在当前目录以及当前目录的所有子目录下查找文件名包含指定字符串的文件,并打印出相对路径。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import os

def search(s, path=os.getcwd()):
filelst = [x for x in os.listdir(path)]
for filename in filelst:
filepath = os.path.join(path, filename)
# print('Searching: ', path, '\nWith: ', filepath)
if os.path.isfile(filepath):
if s in filename:
print(os.path.relpath(filepath))
elif os.path.isdir(filepath):
search(s, filepath)

if __name__ == '__main__':
s = input('Enter the string: ')
search(s)

这题还是挺有意思的,用户自己定义搜索的字符串,我们不仅要找出当前目录下包含该字符串的文件,还要搜索所有的子目录。我们可以把搜索一个目录的过程封装为 search 函数,并采用递归的方式来实现其子目录的搜索。思路如下:

  1. 获取当前目录的所有文件&目录名,使用 os.listdir() 函数可以实现,把这些名称放在一个列表里保存;

  2. 接下来逐个遍历并判断列表中的元素是文件还是目录,可以使用 os.path.isfile()os.path.isdir() 函数;

    • 如果当前遍历到的元素是文件,则使用 os.path.relpath() 函数输出文件的相对路径;
    • 如果当前遍历到的元素是目录,则将该目录的路径传入 search 函数。

特别地,我们要注意这些函数应该输入什么和会输出什么。os.path.relpath() 函数接收一条完整的绝对路径,并输出相对于当前工作路径(在命令行中执行该Python文件时所处的路径)的相对路径,所以我们要先构造出正确的绝对路径,才能获取正确的相对路径。

os.path.isdir() 可以接收相对路径也可以接收绝对路径,因为我们使用 os.listdir() 只能获得文件或目录的名称,在搜索子目录时,这些名称并不是相对于当前工作路径的相对路径,所以不能直接传入 os.listdir() 中,必须先构造绝对路径,然后再判断。

为什么不使用 os.path.abspath() 函数来生成绝对路径呢?因为传入 os.path.abspath() 函数的必须是一条正确的相对路径,才会得到正确的相对路径。举个例子,当前工作路径是 C:\Users\Administrator\Desktop,其子目录 test1 中有一个文件 test2.py,如果我们使用 os.path.abspath('test2.py'),那么得到的绝对路径就变成了 C:\Users\Administrator\Desktop\test2.py,显然是不对的。



序列化

序列化和反序列化

在程序运行的过程中,所有的变量都保存在内存中,而一旦程序结束,变量所占用的内存就会被操作系统全部回收。但是,有时候,我们希望通过程序修改了某个变量的值之后,能够让另一个程序能调用这个变量。比方说在程序1中定义了一个 list,并且经过某些高开销的操作修改了这个 list 的值。如果我们想在程序2中使用程序1中修改后的 list,按之前的做法就只能把程序1作为一个模块,在程序2中执行程序1的代码,这样一来,就必须重复执行高开销的操作了。有没有解决这个问题的方法呢?有的,答案就是序列化

我们把将变量从内存中保存的格式变成可存储或可传输的格式这个过程称之为序列化,在Python中叫 pickling,在其他语言中也被称之为 serializationmarshallingflattening 等等,都是一个意思。经过序列化之后,内存中的变量就由原来的格式(某种数据结构/类型)转换为特定的格式,从而可以被存储或传输,这样另一个程序需要用到时就可以直接读取,而不必重复计算了。

反过来,把将变量内容从序列化的对象重新读到内存里还原为原来的格式这一过程称之为反序列化,即 unpickling


序列化(pickle)

01Python基础中,我们就知道传输和存储都是以字节(bytes)为单位的,所以这节首先介绍一种将变量序列化为 bytes 对象的方法,Python提供了 pickle 模块来实现这一功能。

dict 为例,将一个 dict 类型的对象序列化并写入文件:

1
2
3
4
>>> import pickle
>>> d = dict(name='Bob', age=20, score=88)
>>> pickle.dumps(d)
b'\x80\x03}q\x00(X\x03\x00\x00\x00ageq\x01K\x14X\x05\x00\x00\x00scoreq\x02KXX\x04\x00\x00\x00nameq\x03X\x03\x00\x00\x00Bobq\x04u.'

pickle.dumps() 函数可以把任意变量序列化为一个 bytes 对象。我们可以把这个 bytes 对象写入文件。此外,我们也可以用 pickle.dump() 函数直接把对象序列化后写入一个 file-like Object

1
2
3
>>> f = open('dump.txt', 'wb')
>>> pickle.dump(d, f)
>>> f.close()

打开 dump.txt 文件,我们会看到一堆乱七八糟无法阅读的内容,这些都是Python保存的对象信息。

当我们需要还原变量,也即把对象从磁盘读到内存时,可以先把内容读入到一个 bytes 对象中,然后用 pickle.loads() 方法反序列化出对象,也可以直接用 pickle.load() 方法从一个 file-like Object 中直接反序列化出对象。打开另一个Python命令行,试试反序列化刚才保存的对象:

1
2
3
4
5
>>> f = open('dump.txt', 'rb')
>>> d = pickle.load(f)
>>> f.close()
>>> d
{'age': 20, 'score': 88, 'name': 'Bob'}

可以看到我们成功地还原了这个 dict 类型的对象。

有了 Pickle 之后,我们可以方便地在Python中进行序列化和反序列化。但是,和所有其他编程语言的序列化问题一样,Pickle 是一种Python特有的序列化解决方案,它只能用于Python,甚至不同版本的Python彼此都可能不兼容。如果我们使用Python写程序,而别人使用其他语言,比如Java,C++等,它们没有 Pickle 模块也就没办法进行反序列化了。


JSON

如果我们要在不同的编程语言之间传递对象,就必须把对象序列化为通用的标准格式,比如序列化XML(Extensible Markup Language,可扩展标记语言)。但更好的方法是序列化为JSON(JavaScript Object Notation,JavaScript对象表示法)。因为JSON表示出来就是一个字符串,可以被所有语言读取,也可以方便地存储到磁盘或者通过网络传输。JSON不仅是标准格式,并且比XML更快,还可以直接在Web页面中读取,非常方便。

比较一下Python内置的数据类型和JSON中的表示方式:

Python类型 JSON表示
dict {}
list []
str "string"
int, float 10, 1234.56
True/False true/false
None null

Python内置的 json 模块提供了非常完善的Python对象到JSON格式的转换方法。同样对一个 dict 进行序列化,方法如下:

1
2
3
4
>>> import json
>>> d = dict(name='Bob', age=20, score=88)
>>> json.dumps(d)
'{"age": 20, "score": 88, "name": "Bob"}'

dumps() 方法返回一个 str,内容就是标准的JSON。类似的,dump() 方法可以直接把JSON写入一个 file-like Object

要把JSON反序列化为Python对象,用 loads() 或者对应的 load()方法,前者把JSON的字符串反序列化,后者从 file-like Object 中读取字符串并反序列化:

1
2
3
>>> json_str = '{"age": 20, "score": 88, "name": "Bob"}'
>>> json.loads(json_str)
{'age': 20, 'score': 88, 'name': 'Bob'}

由于JSON标准规定JSON编码是UTF-8,所以我们总是能正确地在Python的 str 与JSON之间的转换。


JSON进阶

Python的 dict 对象可以直接序列化为JSON的 {},不过很多时候,Python自带的数据结构并不足以实现我们的需求,此时我们会使用自定义的类来表示对象。比如定义一个Student类,并尝试序列化该类的实例:

1
2
3
4
5
6
7
8
9
10
import json

class Student(object):
def __init__(self, name, age, score):
self.name = name
self.age = age
self.score = score

s = Student('Bob', 20, 88)
print(json.dumps(s))

运行代码,毫不留情地得到一个 TypeError

1
2
3
Traceback (most recent call last):
...
TypeError: <__main__.Student object at 0x10603cc50> is not JSON serializable

错误的原因是Student对象不是一个可序列化为JSON的对象

这样看来还是不够实用呀,别急,我们再仔细看看 dumps() 方法的参数列表,可以发现,除了第一个必须的 obj 参数外,dumps() 方法还提供了一大堆的可选参数:

1
2
3
4
5
6
7
>>> help(json.dumps)
Help on function dumps in module json:

dumps(obj, skipkeys=False, ensure_ascii=True, check_circular=True, allow_nan=True, cls=None,
indent=None, separators=None, default=None, sort_keys=False, **kw)

...

这些可选参数就是允许我们对JSON序列化进行定制的。前面的代码之所以无法把Student类实例序列化为JSON,是因为默认情况下,dumps() 方法不知道如何将Student实例变为一个JSON的 {} 对象。

可选参数 default 允许我们传入一个可以把传入对象变得可序列化的函数,我们只需要为Student专门写一个转换函数,再把函数传进去即可,例如定义:

1
2
3
4
5
6
def student2dict(std):
return {
'name': std.name,
'age': std.age,
'score': std.score
}

这样,Student实例首先被 student2dict() 函数转换成 dict,然后再被序列化为JSON:

1
2
>>> print(json.dumps(s, default=student2dict))
{"age": 20, "name": "Bob", "score": 88}

不过,下次如果遇到一个Teacher类的实例,我们还是无法把Teacher类的实例序列化为JSON。有没有更方便的做法呢?有的,我们可以偷个懒,利用 __dict__ 属性即可:

1
print(json.dumps(s, default=lambda obj: obj.__dict__))

通常类的实例都有一个 __dict__ 属性,它就是一个 dict。但也有少数例外,比如定义了 __slots__ 的类(这样的类没有 __dict__ 属性)。

同样的道理,如果我们要把JSON反序列化为一个Student对象实例,loads()方法会首先转换出一个 dict 对象,然后,参数 object_hook 则允许我们传入一个函数,负责把 dict 转换为Student实例:

1
2
def dict2student(d):
return Student(d['name'], d['age'], d['score'])

运行结果如下:

1
2
3
>>> json_str = '{"age": 20, "score": 88, "name": "Bob"}'
>>> print(json.loads(json_str, object_hook=dict2student))
<__main__.Student object at 0x10cd3c190>

打印出的是反序列化后的Student实例对象。


小结

Python语言特定的序列化模块是 pickle,但如果想把序列化做得更通用、更符合Web标准,可以使用 json 模块。

json 模块的 dumps()loads() 函数是定义得非常好的接口的典范。当我们使用时,只需要传入一个必须的参数。但是,当默认的序列化或反序列机制不满足我们的要求时,我们又可以传入更多的参数来定制序列化或反序列化的规则,既做到了接口简单易用,又做到了充分的扩展性和灵活性。

作者

ฅ´ω`ฅ

发布于

2017-06-24

更新于

2021-06-08

许可协议


评论