Skip to content

Latest commit

 

History

History
1071 lines (727 loc) · 42.4 KB

File metadata and controls

1071 lines (727 loc) · 42.4 KB

九、文件和资源管理

读取和写入文件是许多真实程序的关键部分。然而,文件的概念有些抽象。在某些情况下,文件可能意味着硬盘上的字节集合;在其他情况下,它可能意味着,例如,远程系统上的 HTTP 资源。这两个实体共享一些行为。例如,您可以从每个文件中读取一个字节序列。同时,它们也不完全相同。例如,您通常可以将字节写回本地文件,而使用 HTTP 资源则无法这样做。

在本章中,我们将介绍 Python 对处理文件的基本支持。由于处理本地文件既常见又重要,我们将主要关注如何处理它们。但是,请注意,Python 及其库生态系统为许多其他类型的实体提供了类似于文件的API,包括基于 URI 的资源、数据库和许多其他数据源。使用公共 API 非常方便,并且可以轻松编写代码,无需更改即可针对各种数据源工作。

在本章中,我们还将介绍上下文管理器,它是 Python 管理资源的主要手段之一。上下文管理器允许您编写在异常情况下健壮且可预测的代码,确保资源(如文件)在发生错误时正确关闭并说明原因。

文件夹

要用 Python 打开本地文件,我们调用内置的open()函数。这需要许多参数,但最常用的是:

  • 文件:文件的路径。这是必需的
  • 模式:读、写、附加和二进制或文本。这是可选的,但为了清晰起见,我们建议始终指定它。显式比隐式好。
  • 编码:如果文件包含编码文本数据,则使用哪种编码。指定这一点通常是个好主意。如果不指定,Python 将为您选择默认编码。

二进制和文本模式

当然,在文件系统级别,文件只包含一系列字节。然而,Python 区分以二进制模式和文本模式打开的文件,即使底层操作系统不这样做。在二进制模式下打开文件时,指示 Python 使用文件中的数据而不进行任何解码;二进制模式文件反映文件中的原始数据。

另一方面,以文本模式打开的文件将其内容视为包含str类型的文本字符串。当您从文本模式文件中获取数据时,Python 首先使用平台相关编码或(如果提供)编码参数open()对原始字节进行解码。

默认情况下,文本模式文件还支持 Python 的通用换行符。这会导致程序字符串('\n')中的单个可移植换行符与文件系统中存储的原始字节中的平台相关换行符表示之间的转换(例如 Windows 上的回车换行符('\r\n')

编码的重要性

正确的编码对于正确解释文本文件的内容至关重要,因此我们想对此稍加探讨。Python 无法可靠地确定文本文件的编码,因此它不会尝试。然而,在不知道文件编码的情况下,Python 无法正确操作文件中的数据。这就是为什么告诉 Python 使用哪种编码是至关重要的。

如果不指定编码,Python 将使用sys.getdefaultencoding()中的默认值。在本例中,默认编码为“utf-8”:

>>> import sys
>>> sys.getdefaultencoding()
'utf-8'

但是,请始终记住,不能保证您系统上的默认编码与您希望与之交换文件的另一个系统上的默认编码相同。对于所有相关人员来说,最好在对open()的调用中指定文本到字节的编码,从而有意识地做出决定。您可以在Python 文档中获得支持的文本编码列表。

打开文件进行写入

让我们以写入模式打开一个文件,开始处理文件。我们将明确使用 UTF-8 编码,因为我们无法知道默认编码是什么。我们还将使用关键字参数使事情更加清楚:

>>> f = open('wasteland.txt', mode='wt', encoding='utf-8')

第一个参数是文件名。mode 参数是一个字符串,包含具有不同含义的字母。在这种情况下,w表示写入,而t表示文本

所有模式串应包括readwriteappend模式之一。此表列出了模式代码及其含义,格式如下:代码含义:

  • r:打开文件读取。流位于文件的开头。这是默认设置。
  • r+:开放读写。流位于文件的开头。
  • **w:**将文件截断为零长度或创建用于写入的文件。流位于文件的开头。
  • w+:开放读写。如果文件不存在,则创建该文件,否则将截断该文件。流位于文件的开头。
  • a:开放写作。如果文件不存在,则创建该文件。流位于文件的末尾。对文件的后续写入将始终以文件的当前结尾结束,而不考虑任何中间查找或类似操作。
  • a+开放读写。如果文件不存在,则创建该文件。流位于文件的末尾。对文件的后续写入将始终以文件的当前结尾结束,而不考虑任何中间查找或类似操作。

**其中一项应与下表中的选择器相结合,用于指定以下格式代码中的文本二进制模式:含义:

  • t:解释为编码文本字符串的文件内容。文件中的字节将根据指定的文本编码进行编码和解码,通用换行翻译将生效(除非明确禁用)。所有从文件中写入和读取数据的方法都接受并返回str对象。这是默认的
  • b:文件内容按原始字节处理。所有从文件中写入和读取数据的方法都接受和返回 bytes 对象。

典型模式字符串的示例可能是wb用于写入二进制或 at 用于追加文本。尽管模式代码的两部分都支持默认值,但为了可读性,我们建议显式。

open()返回的对象的确切类型取决于文件的打开方式。这是动态输入的行动!然而,在大多数情况下,open()返回的实际类型并不重要。只要知道返回的对象是一个类似于的文件的对象就足够了,因此我们可以期望它支持某些属性和方法。

写入文件

我们之前已经展示了如何请求help()模块、方法和类型,但实际上我们也可以请求实例上的帮助。当你记住一切都是一个对象时,这是有道理的。

>>> help(f)
. . .
 |  write(self, text, /)
 |      Write string to stream.
 |      Returns the number of characters written (which is always    
 |      equal to the length of the string).
. . .

浏览帮助,可以看到f支持write()方法。使用q退出帮助并继续在 REPL。

现在,让我们使用write()方法向文件中写入一些文本:

>>> f.write('What are the roots that clutch, ')
32

write()的调用返回写入文件的代码点或字符数。让我们再添加几行:

>>> f.write('what branches grow\n')
19
>>> f.write('Out of this stony rubbish? ')
27

您会注意到,我们在写入文件的文本中显式地包含了换行符。呼叫者有责任在需要换行符的地方提供换行符;Python 不提供writeline()方法。

关闭文件

写完后,记得调用close()方法关闭文件:

>>> f.close()

请注意,只有在关闭该文件后,我们才能确定所写入的数据对外部进程可见。关闭文件很重要!

还要记住,关闭文件后,您将无法再读取或写入文件。尝试这样做将导致异常。

Python 之外的文件

如果您现在退出 REPL,并查看文件系统,您可以看到您确实创建了一个文件。在 Unix 上使用 ls 命令:

$ ls -l
-rw-r--r--   1 rjs  staff    78 12 Jul 11:21 wasteland.txt

您应该可以看到带有78字节的wasteland.txt文件。

在 Windows 上使用dir

> dir
 Volume is drive C has no label.
 Volume Serial Number is 36C2-FF83

 Directory of c:\Users\pyfund

12/07/2013  20:54                79 wasteland.txt
 1 File(s)             79 bytes
 0 Dir(s)  190,353,698,816 bytes free

在本例中,您应该看到带有79 byteswasteland.txt,因为 Python 对文件的通用换行行为已将行结尾转换为平台的本机结尾。

write()方法返回的数字是传递给write()的字符串中的码点(或字符)数,不是编码和通用换行翻译后写入文件的bytes数。通常,在处理文本文件时,您不能对write()返回的数量求和以确定文件的字节长度。

读取文件

为了回读文件,我们再次使用了open(),但这次我们传递了‘rt’,用于读取文本,作为模式:

>>> g = open('wasteland.txt', mode='rt', encoding='utf-8')

如果我们知道要读取多少字节,或者如果我们想读取整个文件,我们可以使用read()。回顾我们的 REPL,我们可以看到第一次写入的长度是32个字符,因此让我们通过调用read()方法来了解这一点:

>>> g.read(32)
'What are the roots that clutch, '

在文本模式下,read()方法接受从文件中读取的字符数,而不是字节数。该调用返回文本,并将文件指针前进到所读取内容的末尾。因为我们是以文本模式打开文件的,所以返回类型为str

要读取文件中的所有剩余数据,我们可以无参数调用read()

>>> g.read()
'what branches grow\nOut of this stony rubbish? '

这给我们一个字符串中的两行的部分-注意中间的换行符。

在文件末尾,对read()的进一步调用返回一个空字符串:

>>> g.read()
''

通常,当我们读完一个文件后,我们会把它读出来。不过,在本练习中,我们将保持文件打开,并使用参数为零的seek()将文件指针移回文件的开头:

>>> g.seek(0)
0

seek()的返回值是新的文件指针位置。

逐行阅读

read()用于文本是相当尴尬的,谢天谢地,Python 为逐行读取文本文件提供了更好的工具。第一个是readline()功能:

>>> g.readline()
'What are the roots that clutch, what branches grow\n'
>>> g.readline()
'Out of this stony rubbish? '

每次调用readline()都会返回一行文本。如果文件中存在换行符,则返回的行由单个换行符终止。这里的最后一行不会以换行符终止,因为文件末尾没有换行符序列。不应该依赖由换行符终止的readline()返回的字符串。请记住,通用换行符支持已经将任何平台本机换行符序列转换为'\n'

一旦到达文件末尾,进一步调用readline()返回空字符串:

>>> g.readline()
''

一次读多行

让我们再次倒回文件指针,以不同的方式读取文件:

>>> g.seek(0)

有时,当我们知道我们想要读取文件中的每一行时——如果我们确信我们有足够的内存这样做——我们可以使用readlines()方法将文件中的所有行读取到列表中:

>>> g.readlines()
['What are the roots that clutch, what branches grow\n',
'Out of this stony rubbish? ']

如果解析文件涉及到在行之间来回跳跃,这一点尤其有用;使用行列表要比使用字符文件流容易得多。

这一次,我们将在继续之前关闭该文件:

>>> g.close()

附加到文件

有时我们希望附加到现有文件中,我们可以使用'a'模式来实现。在此模式下,打开文件进行写入,并将文件指针移动到任何现有数据的末尾。在本例中,我们将'a''t'结合起来,明确说明如何使用文本模式:

>>> h = open('wasteland.txt', mode='at', encoding='utf-8')

虽然 Python 中没有writeline()方法,但是有一个writelines()方法可以将一系列字符串写入流。如果您想在字符串上添加行尾,您必须自己提供。这一点乍看起来可能有些奇怪,但它保留了与readlines()的对称性,同时也让我们能够灵活地使用writelines()将任何可编辑的字符串序列写入文件:

>>> h.writelines(
... ['Son of man,\n',
... 'You cannot say, or guess, ',
... 'for you know only,\n',
... 'A heap of broken images, ',
... 'where the sun beats\n'])
>>> h.close()

请注意,这里只完成了三行-我们说已完成,因为我们附加到的文件本身并没有以换行结束。

作为迭代器的文件对象

这些日益复杂的文本文件读取工具的高潮是,文件对象支持迭代器协议。当您迭代一个文件时,每次迭代都会产生文件中的下一行。这意味着它们可以用于for-循环和任何其他可以使用迭代器的地方。此时,我们将借此机会创建一个 Python 模块文件files.py

import sys

def main(filename):
 f = open(filename, mode='rt', encoding='utf-8')
 for line in f:
 print(line)
 f.close()

if __name__ == '__main__':
 main(sys.argv[1])

我们可以直接从系统命令行调用它,传递文本文件的名称:

$ python3 files.py wasteland.txt
What are the roots that clutch, what branches grow

Out of this stony rubbish? Son of man,

You cannot say, or guess, for you know only

A heap of broken images, where the sun beats

你会注意到这首诗的每一行之间都有空行。这是因为文件中的每一行都以新行结尾,然后print()添加自己的行。为了解决这个问题,我们可以使用strip()方法在打印之前删除每行末尾的空白。相反,我们将使用stdout流的write()方法。这是完全与我们之前写入文件时使用的write()方法相同,并且可以使用,因为stdout流本身就是一个类似文件的对象。我们从sys模块获得stdout流的引用:

import sys

def main(filename):
 f = open(filename, mode='rt', encoding='utf-8')
 for line in f:
 sys.stdout.write(line)
 f.close()

if __name__ == '__main__':
 main(sys.argv[1])

如果我们重新运行我们的程序,我们会得到:

$ python3 files.py wasteland.txt
What are the roots that clutch, what branches grow
Out of this stony rubbish? Son of man,
You cannot say, or guess, for you know only
A heap of broken images, where the sun beats

现在,唉,是时候从二十世纪最重要的一首诗开始,着手处理一些类似于激动人心的背景管理的东西了。

上下文管理器

对于下一组示例,我们需要一个包含一些数字的数据文件。使用下面recaman.py中的代码,我们将一个名为Recaman 序列的数字序列写入文本文件,每行一个数字:

import sys
from itertools import count, islice

def sequence():
 """Generate Recaman's sequence."""
 seen = set()
 a = 0
 for n in count(1):
 yield a
 seen.add(a)
 c = a - n
 if c < 0 or c in seen:
 c = a + n
 a = c

def write_sequence(filename, num):
 """Write Recaman's sequence to a text file."""
 f = open(filename, mode='wt', encoding='utf-8')
 f.writelines("{0}\n".format(r)
 for r in islice(sequence(), num + 1))
 f.close()

if __name__ == '__main__':
 write_sequence(filename=sys.argv[1],
 num=int(sys.argv[2]))

雷卡曼的序列本身对这个练习并不重要;我们只需要一种生成数字数据的方法。因此,我们不会解释sequence()生成器。尽管如此,请随意尝试。

该模块包含一个用于生成 Recaman 编号的生成器和一个使用writelines()方法将序列开始写入文件的函数。生成器表达式用于将每个数字转换为字符串,并添加一个newline. itertools.islice()用于截断原本无限的序列。

我们将通过执行模块,将文件名和序列长度作为命令行参数传递,将前 1000 个 Recaman 编号写入文件:

$ python3 recaman.py recaman.dat 1000

现在让我们制作一个补充模块series.py,将此数据文件读回:

"""Read and print an integer series."""

import sys

def read_series(filename):
 f = open(filename, mode='rt', encoding='utf-8')
 series = []
 for line in f:
 a = int(line.strip())
 series.append(a)
 f.close()
 return series

def main(filename):
 series = read_series(filename)
 print(series)

if __name__ == '__main__':
 main(sys.argv[1])

我们只需从打开的文件中一次读取一行,通过调用strip()string 方法去除换行符,并将其转换为整数。如果我们从命令行运行它,一切都应按预期工作:

$ python3 series.py recaman.dat
[0, 1, 3, 6, 2, 7, 13,
 ...
,3683, 2688, 3684, 2687, 3685, 2686, 3686]

现在让我们故意创造一个例外情况。在文本编辑器中打开recaman.dat并将其中一个数字替换为非字符串化整数:

0
1
3
6
2
7
13
oops!
12
21

保存文件,重新运行series.py

$ python3 series.py recaman.dat
Traceback (most recent call last):
 File "series.py", line 19, in <module>
 main(sys.argv[1])
 File "series.py", line 15, in main
 series = read_series(filename)
 File "series.py", line 9, in read_series
 a = int(line.strip())
ValueError: invalid literal for int() with base 10: 'oops!'

int()构造函数在通过我们新的无效行时引发ValueError。异常未经处理,因此程序以堆栈跟踪终止。

最后,使用数据库管理资源

这里的一个问题是我们的f.close()调用从未执行过。

为了解决这个问题,我们可以插入一个tryfinally块:

def read_series(filename):
 try:
 f = open(filename, mode='rt', encoding='utf-8')
 series = []
 for line in f:
 a = int(line.strip())
 series.append(a)
 finally:
 f.close()
 return series

现在,即使存在异常,文件也将始终关闭。进行此更改为另一次重构打开了机会:我们可以用列表推导式替换for-循环,return此列表直接:

def read_series(filename):
 try:
 f = open(filename, mode='rt', encoding='utf-8')
 return [ int(line.strip()) for line in f ]
 finally:
 f.close()

即使在这种情况下,close()仍然会被调用;无论try块如何退出,finally块都被调用。

这座城市有很多街区

到目前为止,我们的示例都遵循了一种模式:open()一个文件,使用该文件,close()该文件。close()很重要,因为它会通知底层操作系统您已经完成了对文件的处理。如果在处理完文件后不关闭该文件,则可能会丢失数据。可能存在缓冲的挂起写入,这些写入可能无法完全写入。此外,如果打开大量文件,系统可能会耗尽资源。因为我们总是想将每个open()close()配对,所以我们希望有一种机制,即使我们忘记了,也能加强这种关系。

这种对资源清理的需求非常普遍,Python 实现了一个名为with-blocks 的特定控制流结构来支持它。with-块可用于支持上下文管理器协议的任何对象,其中包括open()返回的文件对象。利用文件对象是上下文管理器这一事实,我们的read_series()功能可以变得简单:

def read_series(filename):
 with open(filename, mode='rt', encoding='utf-8') as f:
 return [int(line.strip()) for line in f]

我们不再需要显式调用close(),因为 with 构造将在执行退出块时为我们调用它,无论我们如何退出块。

现在,我们可以返回并修改我们的 Recaman 系列编写程序,以使用with块,再次消除对显式close()的需要:

def write_sequence(filename, num):
 """Write Recaman's sequence to a text file."""
 with open(filename, mode='wt', encoding='utf-8') as f:
 f.writelines("{0}\n".format(r)
 for r in islice(sequence(), num + 1))

禅宗时刻

Figure 9.1: Beautiful is better than ugly

with-block 语法如下所示:

with EXPR as VAR:
 BLOCK

这就是所谓的句法糖,因为try的排列更加复杂。。。excepttry。。。finally街区:

mgr = (EXPR)
exit = type(mgr).__exit__  # Not calling it yet
value = type(mgr).__enter__(mgr)
exc = True
try:
 try:
 VAR = value  # Only if "as VAR" is present
 BLOCK
 except:
 # The exceptional case is handled here
 exc = False
 if not exit(mgr, *sys.exc_info()):
 raise
 # The exception is swallowed if exit() returns true
finally:
 # The normal and non-local-goto cases are handled here
 if exc:
 exit(mgr, None, None, None)

你喜欢哪一种?

我们中很少有人希望我们的代码看起来如此复杂,但如果没有 with 语句,代码就需要这样复杂。糖可能对你的健康没有好处,但对你的代码来说却非常健康!

二进制文件

到目前为止,我们已经研究了文本文件,其中我们将文件内容作为 Unicode 字符串处理。但是,在许多情况下,文件包含的数据不是编码文本。在这些情况下,我们需要能够处理文件中存在的确切字节,而无需任何中间编码或解码。这就是二进制模式的用途。

BMP 文件格式

为了演示二进制文件的处理,我们需要一种有趣的二进制数据格式。BMP 是一种包含独立于设备的位图的图像文件格式。这很简单,我们可以从头开始制作 BMP 文件编写器。将以下代码放入名为bmp.py的模块中:

# bmp.py

"""A module for dealing with BMP bitmap image files."""

def write_grayscale(filename, pixels):
 """Creates and writes a grayscale BMP file.

 Args:
 filename: The name of the BMP file to me created.

 pixels: A rectangular image stored as a sequence of rows.
 Each row must be an iterable series of integers in the
 range 0-255.

 Raises:
 OSError: If the file couldn't be written.
 """
 height = len(pixels)
 width = len(pixels[0])

 with open(filename, 'wb') as bmp:
 # BMP Header
 bmp.write(b'BM')

 # The next four bytes hold the filesize as a 32-bit
 # little-endian integer. Zero placeholder for now.
 size_bookmark = bmp.tell()
 bmp.write(b'\x00\x00\x00\x00')

 # Two unused 16-bit integers - should be zero
 bmp.write(b'\x00\x00')
 bmp.write(b'\x00\x00')

 # The next four bytes hold the integer offset
 # to the pixel data. Zero placeholder for now.
 pixel_offset_bookmark = bmp.tell()
 bmp.write(b'\x00\x00\x00\x00')

 # Image Header
 bmp.write(b'\x28\x00\x00\x00')  # Image header size in bytes -        
                                        40 decimal
 bmp.write(_int32_to_bytes(width))   # Image width in pixels
 bmp.write(_int32_to_bytes(height))  # Image height in pixels
 # Rest of header is      
                                            essentially fixed
 bmp.write(b'\x01\x00')          # Number of image planes
 bmp.write(b'\x08\x00')          # Bits per pixel 8 for 
                                        grayscale
 bmp.write(b'\x00\x00\x00\x00')  # No compression
 bmp.write(b'\x00\x00\x00\x00')  # Zero for uncompressed images
 bmp.write(b'\x00\x00\x00\x00')  # Unused pixels per meter
 bmp.write(b'\x00\x00\x00\x00')  # Unused pixels per meter
 bmp.write(b'\x00\x00\x00\x00')  # Use whole color table
 bmp.write(b'\x00\x00\x00\x00')  # All colors are important

 # Color palette - a linear grayscale
 for c in range(256):
 bmp.write(bytes((c, c, c, 0)))  # Blue, Green, Red, Zero

 # Pixel data
 pixel_data_bookmark = bmp.tell()
 for row in reversed(pixels):  # BMP files are bottom to top
 row_data = bytes(row)
 bmp.write(row_data)
 padding = b'\x00' * ((4 - (len(row) % 4)) % 4)  
            # Pad row to multiple of four bytes
 bmp.write(padding)

 # End of file
 eof_bookmark = bmp.tell()

 # Fill in file size placeholder
 bmp.seek(size_bookmark)
 bmp.write(_int32_to_bytes(eof_bookmark))

 # Fill in pixel offset placeholder
 bmp.seek(pixel_offset_bookmark)
 bmp.write(_int32_to_bytes(pixel_data_bookmark))

这看起来可能很复杂,但正如您将看到的,它相对简单。

为了简单起见,我们决定只处理 8 位灰度图像。它们有一个很好的特性,即每像素一个字节。write_grayscale()函数接受两个参数:文件名和像素值集合。正如 docstring 所指出的,这个集合应该是一个整数序列。例如,int对象的列表就可以了。此外:

  • 每个int必须是从0255的像素值
  • 每个内部列表是从左到右的一行像素
  • 外部列表是从上到下的像素行列表。

我们要做的第一件事是通过计算行数(第 19 行)来计算图像的大小,从而得到高度,第 0 行中的项目数得到宽度(第 20 行)。我们假设,但不检查,所有行都具有相同的长度(在生产代码中,这是我们想要进行的检查)。

接下来,我们使用wb模式字符串open()(第 22 行)以二进制模式写入的文件。我们没有指定编码——这对原始二进制文件毫无意义。

在 with 块中,我们开始编写所谓的“BMP 头”,它以 BMP 格式开头。

标头必须以所谓的“magic”字节序列 b'BM'开头,以将其标识为 BMP 文件。我们使用write()方法(第 24 行),因为文件是以二进制模式打开的,所以必须传递一个 bytes 对象。

接下来的四个字节应该包含一个包含文件大小的 32 位整数,这个值我们还不知道。我们可以提前计算它,但我们将采用另一种方法:我们将编写一个占位符值,然后稍后返回到这一点来填充细节。为了回到这一点,我们使用 file 对象的tell()方法(第 28 行);这为我们提供了文件 poiner 从文件开头的偏移量。我们将把这个偏移量存储在一个变量中,这个变量将充当一种书签。我们写入四个零字节作为占位符(第 29 行),使用转义语法指定零。

接下来的两对字节是未使用的,所以我们也只向它们写入零字节(第 32 行和第 33 行)。

接下来的四个字节表示另一个 32 位整数,该整数应包含从文件开头到像素数据开头的偏移量(以字节为单位)。我们也不知道这个值,所以我们将使用tell()(第 37 行)存储另一个书签,并写入另一个四字节占位符(第 38 行);我们了解更多情况后,很快就会回来。

下一节称为Image Header。我们必须做的第一件事是将图像头的长度写入 32 位整数(第 41 行)。在我们的例子中,头的长度总是 40 字节。我们只是用十六进制硬连线。请注意,BMP 格式为little-endian——最不重要的byte首先写入。

接下来的四个字节是作为little-endian32 位整数的图像宽度。我们在这里调用一个名为_int32_to_bytes()的模块作用域实现细节函数,它将一个 int 对象转换为一个正好包含四个字节的bytes对象(第 42 行)。然后我们再次使用相同的函数来处理Image height(第 43 行)。

对于 8 位灰度图像,报头的其余部分基本上是固定的,这里的细节并不重要,但需要注意的是,整个报头实际上总共有 40 个字节(第 45 行)。

8 位 BMP 图像中的每个像素都是包含 256 个条目的颜色表的索引。每个条目都是一个四字节的 BGR 颜色。对于灰度图像,我们需要在线性比例上写入 256 个 4 字节的灰度值(第 54 行)。这段代码是实验的沃土,对这个函数的自然增强是能够单独提供这个调色板作为可选函数参数。

最后,我们准备写入像素数据,但在写入之前,我们使用 tell()记录当前文件指针偏移量(第 59 行),因为这是我们需要返回并稍后填充的位置之一。

写入像素数据本身非常简单。我们使用reversed()内置函数(第 60 行)翻转行的顺序;BMP 图像自下而上写入。对于每一行,我们只需将 iterable 整数序列传递给bytes()构造函数(第 61 行)。如果任何整数超出范围 0–255,则构造函数将引发一个ValueError

BMP 文件中的每一行像素数据必须是 4 字节长的倍数,与图像宽度无关。为此(第 63 行),我们取行长模 4,给出一个介于 0 和 3 之间的数字(包括 0 和 3),这是我们行末尾所在的上一个四字节边界上的字节数。为了得到将我们带到下一个四字节边界所需的填充字节数,我们从四中减去这个模值,得到一个 4:1 的值。但是,我们永远不想用四个字节填充,只想用一个, 两个或三个,所以我们必须再次取模四,将四字节填充转换为零字节填充。

此值与应用于单个零字节的重复运算符一起使用,以生成包含零、一、两或三个字节的字节对象。我们将其写入文件,以终止每一行(第 65 行)。

在像素数据之后,我们在文件的末尾。我们之前承诺记录该偏移值,因此我们使用tell()(第 68 行)将当前位置记录到文件末尾书签变量中。

现在,我们可以通过用真实的东西替换我们记录的占位符偏移量来返回并履行我们的承诺。首先,文件长度。为此,我们使用_int32_to_bytes()函数将seek()(第 71 行)返回到size_bookmark,我们记得在文件开头附近,write()(第 72 行)将eof_bookmark中存储的大小作为little-endian32 位整数。

最后,我们将seek()(第 75 行)写入pixel_offset_bookmark书签的像素数据偏移占位符,并写入pixel_data_bookmark(第 76 行)中存储的 32 位整数。

当我们退出with-块时,我们可以放心,上下文管理器将关闭文件并将任何缓冲写入提交到文件系统。

位操作符

处理二进制文件通常需要在字节级别分离或组装数据。这正是我们的_int32_to_bytes()函数所做的。我们将快速查看它,因为它显示了 Python 的一些我们以前从未见过的特性:

def _int32_to_bytes(i):
 """Convert an integer to four bytes in little-endian format."""
 return bytes((i & 0xff,
 i >> 8 & 0xff,
 i >> 16 & 0xff,
 i >> 24 & 0xff))

该函数使用>>按位移位)和&按位和运算符从整数值中提取单个字节。请注意,按位 and 使用与符号将其与拼写为“and”的逻辑 and区分开来。>>运算符将整数的二进制表示形式右移指定位数。例程将整数参数 1、2 和 3 字节向右移位,然后在每次移位后用&提取最低有效字节。四个结果整数用于构造元组,然后将元组传递给bytes()构造函数以生成四字节序列。

写入 BMP 文件

为了生成 BMP 图像文件,我们需要一些像素数据。我们已经包括了一个简单的模块fractal.py,它为标志性的Mandelbrot 集合分形生成像素值。 我们不会详细解释分形生成代码,更不用说它背后的数学。但是代码非常简单,并且它不依赖于我们以前没有遇到过的任何 Python 特性:

# fractal.py

"""Computing Mandelbrot sets."""

import math

def mandel(real, imag):
 """The logarithm of number of iterations needed to
 determine whether a complex point is in the
 Mandelbrot set.

 Args:
 real: The real coordinate
 imag: The imaginary coordinate

 Returns:
 An integer in the range 1-255.
 """
 x = 0
 y = 0
 for i in range(1, 257):
 if x*x + y*y > 4.0:
 break
 xt = real + x*x - y*y
 y = imag + 2.0 * x * y
 x = xt
 return int(math.log(i) * 256 / math.log(256)) - 1

def mandelbrot(size_x, size_y):
 """Make an Mandelbrot set image.

 Args:
 size_x: Image width
 size_y: Image height

 Returns:
 A list of lists of integers in the range 0-255.
 """
 return [ [mandel((3.5 * x / size_x) - 2.5,
 (2.0 * y / size_y) - 1.0)
 for x in range(size_x) ]
 for y in range(size_y) ]

关键在于,mandelbrot()函数使用嵌套的列表推导式来生成范围为 0–255 的整数列表。此列表表示分形的图像。每个点的整数值由mandel()函数产生。

生成分形图像

让我们启动一个 REPL 并一起使用分形和 bmp 模块。首先,我们使用mandelbrot()函数将448的图像乘以256像素。使用纵横比为 7:4 的图像可以获得最佳效果:

>>> import fractal
>>> pixels = fractal.mandelbrot(448, 256)

This call to mandelbrot() may take a second or so — our fractal generator is simple rather than efficient!

我们可以查看返回的数据结构:

>>> pixels
[[31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31,   
  31,31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31,    
  31, 31,
 ...
 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49,  
  49]]

这是一个整数列表,就像我们承诺的那样。让我们将这些像素值写入 BMP 文件:

>>> import bmp
>>> bmp.write_grayscale("mandel.bmp", pixels)

查找文件并在图像查看器中打开,例如在 web 浏览器中打开。

灰度 Mandelbrot 集的图片:

Figure 9.2: Grayscale Mandelbrot

读取二进制文件

现在我们正在制作漂亮的 Mandelbrot 图像,我们应该看看如何用 Python 读回这些 BMP。我们不打算编写一个完整的 BMP 阅读器,尽管这将是一个有趣的练习。我们将只做一个简单的函数来确定 BMP 文件中的图像尺寸(以像素为单位)。我们将把代码添加到 bmp.py 中:

def dimensions(filename):
 """Determine the dimensions in pixels of a BMP image.

 Args:
 filename: The filename of a BMP file.

 Returns:
 A tuple containing two integers with the width
 and height in pixels.

 Raises:
 ValueError: If the file was not a BMP file.
 OSError: If there was a problem reading the file.
 """

 with open(filename, 'rb') as f:
 magic = f.read(2)
 if magic != b'BM':
 raise ValueError("{} is not a BMP file".format(filename))

 f.seek(18)
 width_bytes = f.read(4)
 height_bytes = f.read(4)

 return (_bytes_to_int32(width_bytes),
 _bytes_to_int32(height_bytes))

当然,我们使用一个with-语句来管理文件,所以我们不必担心它被正确关闭。在with块中,我们通过查找 BMP 文件中的前两个魔法字节来执行简单的验证检查。如果它们不存在,我们将抛出一个ValueError,这当然会导致上下文管理器关闭该文件。

回顾我们的 BMP 书写器,我们可以确定图像尺寸存储在文件开头的 18 个字节处。我们seek()到该位置,并使用read()方法读取两个块,每个块有四个字节,用于表示维度的两个 32 位整数。因为我们以二进制模式打开文件,read()返回一个bytes对象。我们将这两个字节对象中的每一个传递给另一个名为_bytes_to_int32()的实现细节函数,该函数将它们组合回一个整数。表示图像宽度和高度的两个整数作为元组返回。

_bytes_to_int32()函数使用<<按位左移)和|按位或)以及bytes对象的索引来重新组合整数。请注意,索引到bytes对象会返回一个整数:

def _bytes_to_int32(b):
 """Convert a bytes object containing four bytes into an integer."""
 return b[0] | (b[1] << 8) | (b[2] << 16) | (b[3] << 24)

如果我们使用新的读卡器代码,我们可以看到它确实读取了正确的值:

>>> bmp.dimensions("mandel.bmp")
(448, 256)

文件状对象

Python 中有一个“类似文件的对象”的概念。这不像一个特定的协议那样正式,但是,由于 duck 类型提供的多态性,它在实践中运行良好。

没有详细说明的原因是,不同类型的数据流和设备具有许多不同的功能、期望和行为。因此,事实上,定义一组协议来对它们进行建模将是相当复杂的,而且在实践中,除了自鸣得意的理论成就感之外,它不会给我们带来太多好处。这就是 EAFP 原理的由来:如果你想在一个类似文件的对象上执行seek(),而事先不知道它支持随机访问,那就去试试吧(字面意思!)。如果seek()方法不存在,或者方法确实存在但行为不符合您的预期,请做好失败的准备。

您可能会说“如果它看起来像一个文件,读起来像一个文件,那么它就是一个文件”。

您已经看到了类似文件的对象!

实际上,我们已经看到类似文件的对象在运行;当我们以文本和二进制模式打开文件时,返回给我们的对象实际上是不同的类型,尽管两者都具有明确的类似文件的行为。Python 标准库中还有其他实现类似文件行为的类型,事实上,我们在本书的开头看到了其中一种,当时我们使用urlopen()从 Internet 上的 URL 检索数据。

使用类似文件的对象

让我们通过编写一个函数来计算文件中每行的字数,并将该信息作为列表返回,从而在类似文件的对象中利用这种多态性:

>>> def words_per_line(flo):
...    return [len(line.split()) for line in flo.readlines()]

现在,我们将打开一个常规文本文件,其中包含我们先前创建的 T.S.Eliot 杰作的片段,并将其传递给我们的新函数:

>>> with open("wasteland.txt", mode='rt', encoding='utf-8') as real_file:
...     wpl = words_per_line(real_file)
...
>>> wpl
[9, 8, 9, 9]

real_file的实际类型为:

>>> type(real_file)
<class '_io.TextIOWrapper'>

但是你通常不应该关心这种特殊的类型;是内部的 Python 实现细节。你只是关心它的行为“像一个文件”。

现在,我们将使用类似文件的对象来执行相同的操作,该对象表示 URL 引用的 web 资源:

>>> from urllib.request import urlopen
>>> with urlopen("http://sixty-north.com/c/t.txt") as web_file:
...    wpl = words_per_line(web_file)
...
>>> wpl
[6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 5, 5, 7, 8, 14, 12, 8]

web_file的类型与我们刚才看到的大不相同:

>>> type(web_file)
<class 'http.client.HTTPResponse'>

但是,由于它们都是类似文件的对象,因此我们的函数可以同时处理这两个对象。

像文件一样的物体没有什么神奇之处;这只是一个方便且相当非正式的描述,我们可以对通过 duck 类型利用的对象进行一组期望。

其他资源

with-语句构造可用于实现上下文管理器协议的任何类型的对象。在这本书中,我们不会向您展示如何实现上下文管理器——为此,您需要参考Python 熟练工——但我们将向您展示一种使您自己的类在 with 语句中可用的简单方法。将此代码放入模块fridge.py中:

# fridge.py

"""Demonstrate raiding a refrigerator."""

class RefrigeratorRaider:
 """Raid a refrigerator."""

 def open(self):
 print("Open fridge door.")

 def take(self, food):
 print("Finding {}...".format(food))
 if food == 'deep fried pizza':
 raise RuntimeError("Health warning!")
 print("Taking {}".format(food))

 def close(self):
 print("Close fridge door.")

 def raid(food):
 r = RefrigeratorRaider()
 r.open()
 r.take(food)
 r.close()

我们将raid()导入 REPL 并继续狂暴:

>>> from fridge import raid
>>> raid("bacon")
Open fridge door.
Finding bacon...
Taking bacon
Close fridge door.

重要的是,我们记得把门关上,所以食物会保存到下次袭击。让我们尝试另一个稍微不太健康的 raid:

>>> raid("deep fried pizza")
Open fridge door.
Finding deep fried pizza...
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "./fridge.py", line 23, in raid
 r.take(food)
 File "./fridge.py", line 14, in take
 raise RuntimeError("Health warning!")
RuntimeError: Health warning!

这一次,我们被健康警告打断了,没有抽出时间关门。我们可以通过使用 Python 标准库contextlib 模块中名为[closing()](https://docs.python.org/3/library/contextlib.html#contextlib.closing)的函数来解决这个问题。导入函数后,我们将RefrigeratorRaider构造函数调用包为对closing()的调用。这会将我们的对象包在一个上下文管理器中,该上下文管理器在退出之前总是调用包对象上的close()方法。我们使用此 对象初始化with块:

"""Demonstrate raiding a refrigerator."""

from contextlib import closing

class RefrigeratorRaider:
 """Raid a refrigerator."""

 def open(self):
 print("Open fridge door.")

 def take(self, food):
 print("Finding {}...".format(food))
 if food == 'deep fried pizza':
 raise RuntimeError("Health warning!")
 print("Taking {}".format(food))

 def close(self):
 print("Close fridge door.")

 def raid(food):
 with closing(RefrigeratorRaider()) as r:
 r.open()
 r.take(food)
 r.close()

现在,当我们执行 raid 时:

>>> raid("spam")
Open fridge door.
Finding spam...
Taking spam
Close fridge door.
Close fridge door.

我们发现我们对close()的显式调用是不必要的,所以让我们来解决这个问题:

def raid(food):
 with closing(RefrigeratorRaider()) as r:
 r.open()
 r.take(food)

更复杂的实现将检查门是否已经关闭,并忽略其他请求。

那么它有效吗?让我们再来吃一次油炸比萨饼:

>>> raid("deep fried pizza")
Open fridge door.
Finding deep fried pizza...
Close fridge door.
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "./fridge.py", line 23, in raid
 r.take(food)
 File "./fridge.py", line 14, in take
 raise RuntimeError("Health warning!")
RuntimeError: Health warning!

这一次,即使触发了健康警告,上下文管理器仍然为我们关闭了大门。

总结

  • 使用内置的open()功能打开文件,该功能接受文件模式,以控制读/写/附加行为,以及文件是作为原始二进制数据还是编码文本数据处理。
  • 对于文本数据,应指定文本编码。
  • 文本文件处理字符串对象并执行通用换行翻译和字符串编码。
  • 二进制文件处理字节对象,不进行换行翻译或编码。
  • 在编写文件时,您有责任为换行符提供换行符。
  • 使用后应始终关闭文件。
  • 文件提供了各种面向行的读取方法,也是逐行生成的迭代器。
  • 文件是上下文管理器,with-语句可以与上下文管理器一起使用,以确保执行清理操作,例如关闭文件。
  • 类文件对象的概念定义松散,但在实践中非常有用。练习 EAFP 以充分利用它们。
  • 上下文管理器不限于类似文件的对象。我们可以使用contextlib标准库模块中的工具,例如closing()包器来创建我们自己的上下文管理器。

一路上我们发现:

  • help()函数可用于实例对象,而不仅仅是类型。
  • Python 支持位运算符&|<<>>
  1. 也没有任何语言。

  2. 你可以在PEP 343中获得with语句句法对等的全部细节。

  3. 您可以在这里了解有关 BMP 格式的所有信息。

  4. 比如说,序列协议是针对类似元组的对象的。

  5. EP终止更倾向于AskF性。**