【Python Cookbook】文件操作与IO详解(进阶篇)

文件与 IO(三)

  • 9.读取二进制数据到可变缓冲区中(⭐⭐⭐)
  • 9.1 readinto
  • 9.2 memoryview
  • 9.2.1 零复制
  • 9.2.2 与传统方式的对比
  • 传统方式(非零复制)
  • 零复制方式(memoryview)
  • 9.2.3 总结
  • 10.内存映射的二进制文件(⭐⭐⭐)
  • 9.读取二进制数据到可变缓冲区中(⭐⭐⭐)

    你想直接读取二进制数据到一个可变缓冲区中,而不需要做任何的中间复制操作。或者你想原地修改数据并将它写回到一个文件中去。

    9.1 readinto

    为了读取数据到一个可变数组中,使用文件对象的 readinto() 方法。比如:

    import os.path
    
    def read_into_buffer(filename):
        buf = bytearray(os.path.getsize(filename))
        with open(filename, 'rb') as f:
            f.readinto(buf)
        return buf
    

    下面是一个演示这个函数使用方法的例子:

    >>> # Write a sample file
    >>> with open('sample.bin', 'wb') as f:
    ...     f.write(b'Hello World')
    ...
    >>> buf = read_into_buffer('sample.bin')
    >>> buf
    bytearray(b'Hello World')
    >>> buf[0:5] = b'Hello'
    >>> buf
    bytearray(b'Hello World')
    >>> with open('newsample.bin', 'wb') as f:
    ...     f.write(buf)
    ...
    11
    >>>
    

    文件对象的 readinto() 方法能被用来为预先分配内存的数组填充数据,甚至包括由 array 模块或 numpy 库创建的数组。

    和普通 read() 方法不同的是, readinto() 填充已存在的缓冲区,而不是为新对象重新分配内存再返回它们。因此,你可以使用它来避免大量的内存分配操作。比如,如果你读取一个由相同大小的记录组成的二进制文件时,你可以像下面这样写:

    record_size = 32 # Size of each record (adjust value)
    
    buf = bytearray(record_size)
    with open('somefile', 'rb') as f:
        while True:
            n = f.readinto(buf)
            if n < record_size:
                break
            # Use the contents of buf
            ...
    

    使用 f.readinto() 时需要注意的是,你必须检查它的返回值,也就是实际读取的字节数。

    如果字节数小于缓冲区大小,表明数据被截断或者被破坏了(比如你期望每次读取指定数量的字节)。

    最后,留心观察其他函数库和模块中和 into 相关的函数(比如 recv_into()pack_into() 等)。Python 的很多其他部分已经能支持直接的 I/O 或数据访问操作,这些操作可被用来填充或修改数组和缓冲区内容。

    9.2 memoryview

    9.2.1 零复制

    另外有一个有趣特性就是 memoryview,它可以通过 零复制 的方式对已存在的缓冲区执行切片操作,甚至还能修改它的内容。

    🚀 零复制(Zero-Copy)是一种 高效的数据处理技术,它允许程序 直接访问数据缓冲区,而无需在内存中复制数据。传统的数据操作(如切片、修改)通常需要先复制一份数据,而零复制技术避免了这一额外开销,从而提升性能并减少内存占用。

    Python 的 memoryview 对象提供了一种零复制的方式来操作现有的缓冲区(如 bytesbytearraymmap 等),允许:

    1. 直接引用原始数据,而无需复制。
    2. 修改原始数据(如果底层缓冲区可写,如 bytearray)。
    3. 高效切片,即使操作大型数据也不会产生额外内存消耗。

    比如:

    >>> buf
    bytearray(b'Hello World')
    >>> m1 = memoryview(buf)
    >>> m2 = m1[-5:]
    >>> m2
    <memory at 0x100681390>
    >>> m2[:] = b'WORLD'
    >>> buf
    bytearray(b'Hello WORLD')
    >>>
    

    1. 创建 bytearray 缓冲区

    buf = bytearray(b'Hello World')  # 可变的字节数组
    
  • buf 是一个可修改的 bytearray,内容为 b'Hello World'
  • 2. 创建 memoryview 对象

    m1 = memoryview(buf)  # 零复制方式引用 buf
    
  • m1buf 的零复制视图,不复制数据,直接操作 buf 的内存。
  • 3. 对 memoryview 切片(零复制)

    m2 = m1[-5:]  # 获取最后 5 字节的视图(' World')
    
  • m2m1 的一个切片,仍然零复制,不会创建新数据副本。
  • 此时 m2 的内容是 b'World'(但底层仍指向 buf 的对应部分)。
  • 4. 通过 memoryview 修改原始数据

    m2[:] = b'WORLD'  # 修改 m2 的内容
    
  • m2 的内容替换为 b'WORLD',由于 m2 是零复制视图,直接修改了 buf
  • 最终 buf 变为 bytearray(b'Hello WORLD')
  • 9.2.2 与传统方式的对比

    传统方式(非零复制)
    buf = bytearray(b'Hello World')
    sliced = buf[-5:]  # 复制数据,生成新对象
    sliced = b'WORLD'  # 修改的是副本,不影响 buf
    print(buf)  # 仍为 b'Hello World'(未改变)
    
  • 切片会复制数据,修改副本不影响原始数据。
  • 零复制方式(memoryview)
    buf = bytearray(b'Hello World')
    m = memoryview(buf)
    m[-5:][:] = b'WORLD'  # 直接修改原始数据
    print(buf)  # 输出 b'Hello WORLD'
    
  • 切片和修改均直接作用于原始数据。
  • 9.2.3 总结

  • 零复制:通过 memoryview 直接操作原始缓冲区,避免数据拷贝。
  • memoryview 的作用
  • 提供对缓冲区的零复制访问。
  • 支持高效切片和修改(若底层可写)。
  • 适用场景:需要高性能、低内存占用的二进制数据处理任务。
  • 10.内存映射的二进制文件(⭐⭐⭐)

    你想 内存映射一个二进制文件到一个可变字节数组中,目的可能是为了随机访问它的内容或者是原地做些修改。

    使用 mmap 模块来内存映射文件。下面是一个工具函数,向你演示了如何打开一个文件并以一种便捷方式内存映射这个文件。

    import os
    import mmap
    
    def memory_map(filename, access=mmap.ACCESS_WRITE):
        size = os.path.getsize(filename)
        fd = os.open(filename, os.O_RDWR)
        return mmap.mmap(fd, size, access=access)
    
  • access:访问模式,默认 mmap.ACCESS_WRITE(可读写),其他选项:
  • mmap.ACCESS_READ(只读)
  • mmap.ACCESS_COPY(写操作不修改原文件,仅修改内存副本)
  • os.path.getsize(filename):返回文件大小(字节数),用于确定内存映射的大小。
  • os.open(filename, os.O_RDWR)
  • os.open()open() 更底层,返回文件描述符 fd(整数)。
  • os.O_RDWR:以读写模式打开文件。
  • mmap.mmap(fd, size, access=access)
  • fd:文件描述符。
  • size:映射的字节数(通常等于文件大小)。
  • access:访问权限(如 mmap.ACCESS_WRITE)。
  • 返回一个 mmap 对象,可以像操作 bytesbytearray 一样操作文件数据。
  • 为了使用这个函数,你需要有一个已创建并且 内容不为空 的文件。下面是一个例子,教你怎样初始创建一个文件并将其内容扩充到指定大小:

    >>> size = 1000000
    >>> with open('data', 'wb') as f:
    ...     f.seek(size-1)
    ...     f.write(b'\x00')
    ...
    >>>
    
  • f.seek(N) 将文件指针移动到第 N 个字节(从 0 开始计数)。
  • 这里移动到 size - 1(即第 999,999 字节),因为我们要在最后写入 1 字节。
  • b'\x00' 是一个值为 0 的字节(即空字节)。
  • 写入后,文件大小变为 size(1,000,000 字节),未显式写入的部分会自动填充 \x00
  • 下面是一个利用 memory_map() 函数类内存映射文件内容的例子:

    >>> m = memory_map('data')
    >>> len(m)
    1000000
    >>> m[0:10]
    b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
    >>> m[0]
    0
    >>> # Reassign a slice
    >>> m[0:11] = b'Hello World'
    >>> m.close()
    
    >>> # Verify that changes were made
    >>> with open('data', 'rb') as f:
    ... print(f.read(11))
    ...
    b'Hello World'
    >>>
    

    mmap() 返回的 mmap 对象同样也可以作为一个上下文管理器来使用,这时候底层的文件会被自动关闭。比如:

    >>> with memory_map('data') as m:
    ...     print(len(m))
    ...     print(m[0:10])
    ...
    1000000
    b'Hello World'
    >>> m.closed
    True
    >>>
    

    默认情况下,memeory_map() 函数打开的文件同时支持读和写操作。任何的修改内容都会复制回原来的文件中。

    如果需要只读的访问模式,可以给参数 access 赋值为 mmap.ACCESS_READ。比如:

    m = memory_map(filename, mmap.ACCESS_READ)
    

    如果你想在本地修改数据,但是又不想将修改写回到原始文件中,可以使用 mmap.ACCESS_COPY

    m = memory_map(filename, mmap.ACCESS_COPY)
    

    为了随机访问文件的内容,使用 mmap 将文件映射到内存中是一个高效和优雅的方法。例如,你无需打开一个文件并执行大量的 seek()read()write() 调用, 只需要简单的映射文件并使用切片操作访问数据即可。

    一般来讲, mmap() 所暴露的内存看上去就是一个二进制数组对象。但是,你可以使用一个内存视图来解析其中的数据。比如:

    >>> m = memory_map('data')
    >>> # Memoryview of unsigned integers
    >>> v = memoryview(m).cast('I')
    >>> v[0] = 7
    >>> m[0:4]
    b'\x07\x00\x00\x00'
    >>> m[0:4] = b'\x07\x01\x00\x00'
    >>> v[0]
    263
    >>>
    

    1. 内存映射文件

    m = memory_map('data') 
    
  • memory_map('data') 将文件 data 映射到内存,返回一个 mmap 对象 m,可以像 bytes 一样操作。
  • 2. 创建 memoryview 并转换为无符号整数('I'

    v = memoryview(m).cast('I')
    
  • memoryview(m) 创建一个内存视图,允许以不同方式解释底层数据。
  • .cast('I') 将数据解释为 无符号整数(unsigned int,4字节)
  • 'I'struct 模块的格式字符,表示 4 字节无符号整数(小端序)。
  • 此时 v 是一个无符号整数数组,可以直接通过索引访问/修改。
  • 3. 修改无符号整数值

    v[0] = 7
    
  • 将第一个无符号整数(v[0],对应 m[0:4])设为 7
  • 写入后,m[0:4] 的字节变为 b'\x07\x00\x00\x00'(小端序)。
  • 4. 查看底层字节

    m[0:4]
    
  • 输出 b'\x07\x00\x00\x00',即:
  • 十六进制:0x07 0x00 0x00 0x00(小端序表示 7)。
  • 5. 直接修改字节数据

    m[0:4] = b'\x07\x01\x00\x00'
    
  • 直接通过 mmap 对象修改前 4 字节为 b'\x07\x01\x00\x00'(小端序)。
  • 6. 查看无符号整数值

    v[0]
    
  • 输出 263,因为:
  • b'\x07\x01\x00\x00' 在小端序下解析为 0x00000107 = 263
  • 示例扩展
    假设文件 data 初始内容为 b'\x00\x00\x00\x00\x00\x00\x00\x00'(8字节全0):

    # 映射文件并转换为无符号整数数组
    m = memory_map('data')  # 假设文件大小为 8 字节
    v = memoryview(m).cast('I')
    
    # 修改前两个整数
    v[0] = 7       # m[0:4] = b'\x07\x00\x00\x00'
    v[1] = 263     # m[4:8] = b'\x07\x01\x00\x00'
    
    # 验证
    print(m[0:8])  # 输出 b'\x07\x00\x00\x00\x07\x01\x00\x00'
    print(v[0], v[1])  # 输出 7, 263
    

    需要强调的一点是,内存映射一个文件并不会导致整个文件被读取到内存中。也就是说,文件并没有被复制到内存缓存或数组中。相反,操作系统仅仅为文件内容保留了一段虚拟内存。当你访问文件的不同区域时,这些区域的内容才根据需要被读取并映射到内存区域中。而那些从没被访问到的部分还是留在磁盘上。所有这些过程是透明的,在幕后完成!

    如果多个 Python 解释器内存映射同一个文件,得到的 mmap 对象能够被用来在解释器直接交换数据。也就是说,所有解释器都能同时读写数据,并且其中一个解释器所做的修改会自动呈现在其他解释器中。很明显,这里需要考虑同步的问题。但是这种方法有时候可以用来在管道或套接字间传递数据。

    这一小节中函数尽量写得很通用,同时适用于 Unix 和 Windows 平台。要注意的是使用 mmap() 函数时会在底层有一些平台的差异性。
    另外,还有一些选项可以用来创建匿名的内存映射区域。如果你对这个感兴趣,确保你仔细研读了 Python 文档中 这方面的内容 。

    作者:G皮T

    物联沃分享整理
    物联沃-IOTWORD物联网 » 【Python Cookbook】文件操作与IO详解(进阶篇)

    发表回复