view C 级数组
Cython 的类型化 memoryview 还可以 view 一个 C 级数组,并且数组可以是在堆上分配的,也可以是在栈上分配的。如果要 view 一个栈上分配的 C 数组,那么直接将该数组赋值即可,因为数组的大小是固定的(或完整的),Cython 有足够的信息来跟踪这个 C 数组。
import numpy as np # 声明一个 C 数组 cdef int a[3][5][7] # 类型化 memoryview 是 C 连续的 # 因为 C 里面的数组是 C 连续的 cdef int[:, :, :: 1] m = a # 然后将其赋值为 123 m[...] = 123 # 转成 Numpy 中的数组 arr = np.array(m, copy=False) print(np.sum(arr), 3 * 5 * 7 * 123) """ 12915 12915 """ print(arr[:, 1: 3]) """ [[[123 123 123 123 123 123 123] [123 123 123 123 123 123 123]] [[123 123 123 123 123 123 123] [123 123 123 123 123 123 123]] [[123 123 123 123 123 123 123] [123 123 123 123 123 123 123]]] """
上面的 C 数组是在栈区分配的,在赋值给类型化 memoryview 的时候,等号右边写一个数组名即可,因为 Cython 清楚 C 数组的形状。当然我们也可以改为堆区分配:
from libc.stdlib cimport malloc, free import numpy as np cdef int *a = <int *>malloc(3 * 5 * 7 * sizeof(int)) # 很明显, 改成堆区分配的话, 形状的信息就丢失了 # Cython 只知道这个一个 int *,如果将 a 赋值给类型化 memoryview # 编译时会出现 "Cannot convert long * to memoryviewslice" # 因此我们在赋值给类型化 memoryview 的时候, 必须给 Cython 提供更多的信息 # 而 <int[:3, :5, :7]> a 会告诉 Cython 这是一个三维数组, 维度分别是 3 5 7 # 当然我们这里变成 7 5 3 也是可以的, 因为形状是由我们决定的 cdef int[:, :, :: 1] m = <int[:3, :5, :7]> a m[...] = 123 # 转成 Numpy 中的数组 arr = np.array(m, copy=False) print(np.sum(arr), 3 * 5 * 7 * 123) """ 12915 12915 """ print(arr[:, 1: 3]) """ [[[123 123 123 123 123 123 123] [123 123 123 123 123 123 123]] [[123 123 123 123 123 123 123] [123 123 123 123 123 123 123]] [[123 123 123 123 123 123 123] [123 123 123 123 123 123 123]]] """
在 C 级别,光靠一个头指针没有办法确定动态分配的 C 数组的形状,而这一点则需要由我们来确定。因此将一个 C 数组赋值给类型化 memoryview 时,如果数据不正确,那么可能会导致缓冲区溢出、段错误,或者数据损坏等等。
到此我们就算介绍完了类型化 memoryview 的特性,并展示了如何在支持缓冲区协议的 Python 对象和 C 级数组中使用它。如果 Cython 函数中有一个类型化 memoryview 参数,那么可以传递一个支持缓冲区协议的 Python 对象或者 C 数组作为参数进行调用。
然后是返回值,Python 中有一个 memoryview,而 Cython 在 memoryview 的基础上构建了类型化 memoryview。当在函数中想返回一个类型化 memoryview 时,Cython 会根据缓冲区内容(没有拷贝)构建一个 Python 的 memoryview 返回。
但这会有一些问题,假设在函数中返回一个由 C 数组构建的类型化 memoryview,如果这个 C 数组是在堆区通过 malloc 动态分配的,那么返回没有任何问题(表面上);但如果它是在栈区分配的,在函数结束后就会被销毁,而我们说 Cython 又不会对缓冲区内容进行拷贝,因此会出现错误。
所以如果想返回 C 数组给其它函数使用,那么需要在堆区分配。但即便 C 数组在堆区分配,也是存在问题的。那就是当我们不再使用 memoryview 的时候,谁来释放这个在堆上申请的数组呢?如何正确地管理它的内存呢?不过在探讨这个问题之前,我们需要先来说一下另一种 Numpy。
另一种 Numpy
为啥会有另一种 Numpy 呢?因为我们在 Cython 中除了 import numpy 之外,还可以 cimport numpy。
在类型化 memoryview 出现之前,Cython 也可以使用不同的语法来很好地处理 Numpy 数组,这便是原始缓冲区语法。尽管它已经被类型化 memoryview 所取代,但我们依旧可以正常使用它。
# 文件名:cython_test.pyx # 这里一定要使用 cimport numpy # 或者 from numpy cimport ndarray cimport numpy as np # 如果是 import numpy as np # 那么不好意思, np.ndarray 是无法作为参数类型和返回值类型的 # 编译时会报错: 'np' is not a cimported module # 如果是 from numpy import ndarray # 编译时同样报错: 'ndarray' is not a type identifier cpdef np.ndarray func(np.ndarray array): return array
但是注意:此时不能自动编译,因为它依赖 numpy 的一个头文件,所以我们需要通过手动编译的方式。
from pathlib import Path import numpy as np from distutils.core import Extension, setup from Cython.Build import cythonize ext = Extension( "cython_test", ["cython_test.pyx"], # cimport numpy 时会使用 Numpy 提供的一个头文件:arrayobject.h # 但是很明显, 我们并没有指定它的位置 # 因此需要通过 include_dirs 告诉 Cython 编译器去哪里找这个头文件 # 如果没有这个参数, 那么编译时会报错: # fatal error: numpy/arrayobject.h: No such file or directory include_dirs=[str(Path(np.__file__).parent / "core" / "include")]) # 当然 numpy 也为我们封装了一个函数, 直接通过 np.get_include() 即可获取该路径 # 对于我当前的环境来说就是 C:\python38\lib\site-packages\numpy\core\include setup( ext_modules=cythonize(ext, language_level=3), )
编译之后导入测试一下:
import numpy as np from cython_test import func print(func(np.array([[1, 2], [3, 4]]))) """ [[1 2] [3 4]] """ print( func(np.array([["xx", None], [(1, 2), {1, 2}]], dtype="O")) ) """ [['xx' None] [(1, 2) {1, 2}]] """
测试是没有问题的,接收的是 array,返回的也是 array。并且我们看到,这对 array 的类型没有任何限制,但如果我们希望限制 array 的类型、甚至是维度,这个时候该怎么做呢?
Cython 为 numpy 提供了专门的方法,比如希望接收一个元素类型为 int64、维度为 2 的数组,就可以使用 ndarray[long, ndim=2] 这种方式,我们演示一下。
cimport numpy as np # C 类型和 Numpy 的类型要统一 # long 对应 np.int64, int 对应 np.int32 # short 对应 np.int16, char 对应 np.int8 # unsigned long 对应 np.uint64, 其它同理 def func1(np.ndarray[long, ndim=2] array): print(array) def func2(np.ndarray[double, ndim=1] array): print(array) def func3(np.ndarray[object, ndim=1] array): print(array) # 除了作为函数参数和返回值类型之外, 还可以用来声明普通的静态变量 # 比如: cdef np.ndarray[double, ndim=2] arr
编译之后导入测试:
import numpy as np import cython_test # 这里我们传递的时候, 参数和维度一定要匹配 # Numpy 的整型默认是 int64 cython_test.func1(np.array([[1, 2], [3, 4]])) """ [[1 2] [3 4]] """ try: cython_test.func1(np.array([1, 2, 3, 4])) except ValueError as e: print(e) """ Buffer has wrong number of dimensions (expected 2, got 1) """ try: cython_test.func2(np.array([1, 2, 3, 4])) except ValueError as e: print(e) """ Buffer dtype mismatch, expected 'double' but got 'long' """ cython_test.func2(np.array([1, 2, 3, 4], dtype="float64")) """ [1. 2. 3. 4.] """ cython_test.func3(np.array(["a", "b", object], dtype="O")) """ ['a' 'b' <class 'object'>] """
以上就是原始缓冲区语法,现在更推荐类型化 memoryview,虽然它比 Numpy 中的 array 少了许多功能,但我们说这两者之间是可以高效转换的。并且如果是通过 np 来调用的话,那么两者是等价的。举个例子:
import numpy as np def func(int[:, :: 1] m): # m 本身没有 sum 方法 # 但是我们可以将它传递给 np.sum print(np.sum(m)) print(np.sum(m, axis=0)) print(np.sum(m, axis=1)) func(np.array([[1, 2], [3, 4]], dtype="int32")) """ 10 [4 6] [3 7] """
因此这种声明方式是更加推荐的,而且也更加清晰和简洁,以及我们可以使用 pyximport 自动编译了。之前的方式由于依赖一个头文件,必须要手动编译,并告诉 Cython 编译器头文件去哪里找。但是现在不需要了,因为我们根本没有 cimport numpy。
但是以上这些说实话都不能算是优点,所以肯定还有其它的优点,那么都有哪些呢?
1)类型化 memoryview 支持的对象种类非常多,只要它们实现了缓冲区协议,比如:Numpy array, bytes 对象等等,并且它也适用于 C 数组。所以它比原始缓冲区语法更加通用,原始缓冲区语法只适用于 Numpy array。
2)类型化 memoryview 有着更多的选择来控制数组的特性,比如是 C 连续还是 Fortran 连续,是直接访问还是间接访问。并且一些选项可以按照维度逐个控制,而 Numpy 的原始缓冲区语法不提供这种级别的控制。
3)在任何情况下,类型化 memoryview 都有着超越原始缓冲区语法的性能,这一点才是我们最关注的。
包装 C 数组
回到之前的问题,当 C 数组在堆上分配,那么返回之后要如何释放堆区申请的内存呢?我们举个例子:
// heap_malloc.h float *make_matrix_c(int nrows, int ncols); // heap_malloc.c #include <stdlib.h> float *make_matrix_c(int nrows, int ncols) { float *matrix = (float *) malloc(nrows * ncols * sizeof(float)); return matrix; }
以上返回一个在堆上分配的 C 数组:
cdef extern from "heap_malloc.h": float *make_matrix_c(int nrows, int ncols) def make_matrix(int nrows, int ncols): cdef float *arr = make_matrix_c(nrows, ncols) cdef float[:, :: 1] m = <float[:nrows, :ncols]> arr # 因为元素都未初始化, 所以里面的值都是不确定的 # 虽然这无伤大雅, 但是更优雅的处理方式是将值都初始化为零值 m[...] = 0.0 # m 是类型化 memoryview,返回之后会转成 Python 的 memoryview return m
显然这个例子已经无需解释了,我们直接编译:
from distutils.core import Extension, setup from Cython.Build import cythonize ext = Extension("cython_test", ["cython_test.pyx", "heap_malloc.c"], include_dirs=["."]) setup( ext_modules=cythonize(ext, language_level=3), )
编译完成之后将 pyd 文件移到当前目录,导入测试:
import numpy as np import cython_test m = cython_test.make_matrix(3, 4) # 转成 memoryview 返回 print(m) """ <MemoryView of 'array' object> """ print(m.shape) """ (3, 4) """ # 基于 m 创建 numpy 数组 # np.asarray 等价于 np.array,但是它不会拷贝缓冲区 # 而 np.array 默认会拷贝缓冲区 # 当然也可以通过指定 copy=False,让其不拷贝 arr1 = np.asarray(m) arr2 = np.asarray(m) # arr1 和 arr2 都是基于 m 创建的 # 它们使用的都是 m 的缓冲区 # 也就是 heap_malloc.c 里面的 make_matrix_c 函数返回的 C 数组 print(arr1) """ [[0. 0. 0. 0.] [0. 0. 0. 0.] [0. 0. 0. 0.]] """ print(arr2) """ [[0. 0. 0. 0.] [0. 0. 0. 0.] [0. 0. 0. 0.]] """ # 修改 arr1,也会影响 arr2 # 因为它们共享同一个缓冲区 arr1[1, 1] = 123 print(arr2) """ [[ 0. 0. 0. 0.] [ 0. 123. 0. 0.] [ 0. 0. 0. 0.]] """
我们实现的函数 make_matrix 的功能就是初始化一个元素全为 0、行和列分别为 nrows 和 ncols 的数组,然后根据这个数组创建一个 memoryview 并返回。从打印结果来看,代码没有任何问题,很 happy,但事实真是如此吗?
明显不是,因为 arr1 和 arr2 对应的 C 数组是在堆上申请的,那么这个堆区的 C 数组咋办?所以目前这个 make_matrix 函数是存在致命缺陷的,它有内存泄露的风险,而这个风险对于任何一个程序而言都是致命的。
使用 Cython 正确(自动)地管理 C 数组
作为开发者,我们需要对内存负责,但当和 C 共享数组的时候,合适的解决内存问题就变成了一件很棘手的事情,因为 C 语言没有自动管理内存的特性。通常在这种情况下,最干净利索的做法就是复制数据,来澄清各自对数据的所有权。
比如我们可以不返回 memoryview,而是直接创建一个 Numpy 数组返回,并且不使用 copy=False,这样就会将缓冲区拷贝一份。所以结果就是:你的是你的,我的是我的,两者之间没有关系。
from libc.stdlib cimport free import numpy as np cdef extern from "heap_malloc.h": float *make_matrix_c(int nrows, int ncols) def make_matrix(int nrows, int ncols): cdef float *arr = make_matrix_c(nrows, ncols) cdef float[:, :: 1] m = <float[:nrows, :ncols]> arr m[...] = 0.0 # 不加 copy=False,会创建新的缓冲区 result = np.array(m) # 释放掉 C 数组 arr free(<void *> arr) return result
通过将缓冲区拷贝一份,这样就可以放心的释放 C 数组了,不会出现内存泄露。但很明显,如果数据量非常大,我们这么做是不是会影响效率呢?所以这虽然是一个解决问题的办法,但不是最好的办法。
而最好的办法还是共享内存,不要让 Numpy 新建一个缓冲区,而是使用已有的 C 数组,避免内存的拷贝。但这又回到了之前的问题,如果 Python 后续不再使用,那对应的 C 数组应该怎么释放呢?
先来介绍第一种方法:
from libc.stdlib cimport free import numpy as np # 定义一个全局变量 cdef void *release_pointer = NULL cdef extern from "heap_malloc.h": float *make_matrix_c(int nrows, int ncols) def make_matrix(int nrows, int ncols): cdef float *arr = make_matrix_c(nrows, ncols) # 将指针 arr 赋值给全局变量 release_pointer global release_pointer release_pointer = <void *> arr cdef float[:, :: 1] m = <float[:nrows, :ncols]> arr m[...] = 0.0 # 可以返回 memoryview,也可以返回一个 array # 这里直接返回 array,并且不拷贝缓冲区 return np.asarray(m) def dealloc(): if release_pointer != NULL: free(release_pointer)
我们在创建 C 数组的时候,将指针用全局变量保存起来,这样 Python 就可以调用 dealloc 函数释放了。
重新编译,然后导入:
import cython_test while True: # 会在堆区创建一个 C 数组 cython_test.make_matrix(3, 4) # 调用 dealloc 函数将 C 数组释放掉 # 如果没有这一步,内存占用会不断往上涨 cython_test.dealloc()
写了一个死循环,每一次循环都会在堆区申请一个 C 数组,如果不调用 dealloc,那么内存占用会蹭蹭往上涨。但是我们调用了 dealloc,每次不用了就会释放掉,所以内存不会出现泄露。
上面这种做法虽然能解决问题,但仍存在两个缺陷。第一个缺陷是实现方式比较 low,因为每次不用了,还需要手动调用 dealloc 函数进行释放;而第二个缺陷就比较严重了,当第一次调用 make_matrix 函数时,会在堆区分配一个 C 数组,然后全局变量保存该数组的地址。如果再调用一次 make_matrix 函数,那么又会在堆区分配一个 C 数组,然后全局变量会保存这个新数组的地址。那么问题来了,第一次在堆上申请的 C 数组怎么办?
如果文字不好理解的话,我们用代码来解释:
# arr1 会对应一个堆区的 C 数组 # 全局变量 release_pointer 保存该数组的地址 arr1 = cython_test.make_matrix(6, 6) # arr2 也会对应一个堆区的 C 数组 # 全局变量又会保存新的 C 数组的地址 arr2 = cython_test.make_matrix(5, 10) # 此时调用 dealloc 释放的是 arr2 对应的 C 数组 # 那么 arr1 对应的 C 数组咋办? cython_test.dealloc()
相信你应该发现问题所在了,因为全局变量 release_pointer 每次只能保存一个地址,所以在调用完 make_matrix 之后,必须先调用 dealloc 将已有的 C 数组给释放掉,然后才能再一次调用 make_matrix。
比如上面的 arr1,在创建 arr2 之前必须先把 arr1 对应的 C 数组释放掉,否则的话,全局变量就会保存 arr2 对应的 C 数组的地址。那么 arr1 对应的 C 数组,就永远也没有机会释放了。
所以这种做法的局限性比较高(但是方便),而优雅的做法应该是把 C 数组和返回的 Numpy 数组关联起来,一旦当 Numpy 数组被回收,那么就自动释放堆区的 C 数组。
cimport numpy as c_np import numpy as np cdef extern from "heap_malloc.h": float *make_matrix_c(int nrows, int ncols) cdef extern from "numpy/ndarraytypes.h": # 需要使用 Numpy 提供的 C API # 另外 Numpy 的数组在底层对应的结构体是 PyArrayObject void PyArray_ENABLEFLAGS(c_np.PyArrayObject *arr, int flags) def make_matrix(int nrows, int ncols): cdef float *arr = make_matrix_c(nrows, ncols) cdef float[:, ::1] m = <float [:nrows, :ncols]>arr m[...] = 0 # 转成 Numpy 的数组,并且和 C 数组共享内存 result = np.asarray(m) # 对于 Numpy 数组而言,缓冲区有两种选择方式 # 可以使用已有的缓冲区,也可以新创建一个缓冲区 # 如果是新创建的缓冲区,那么该缓冲区就归属于对应的 Numpy 数组 # 当 Numpy 数组被回收时,会顺带将缓冲区一块回收 # 但如果使用已有的缓冲区,那么该缓冲区就不属于 Numpy 数组了 # 比如当前的 result,它就是直接使用已有的 C 数组作为缓冲区 # 那么当 Numpy 数组被回收时,缓冲区是不会被回收的,因为缓冲区不属于它(出现内存泄露) # 但我们通过下面这个函数将 Numpy数组的 flags设置为 NPY_ARRAY_OWNDATA # 相当于告诉 Numpy:缓冲区属于数组 result,如果它被回收了, # 请在 __dealloc__ 里面将缓冲区(C 数组)也释放掉 PyArray_ENABLEFLAGS(<c_np.PyArrayObject *>result, c_np.NPY_ARRAY_OWNDATA) # 此时我们就解决了内存泄漏问题 return result
再来描述一下背后的原理,实现了缓冲区协议的对象,数据都存在缓冲区里面。缓冲区是一个一维数组,由 Py_buffer 里面的 buf 成员指向。缓冲区可以是自己的,也就是对象在创建的时候,也会新建一个缓冲区;当然缓冲区也可以是别人的,就是对象在创建的时候,直接使用已有的缓冲区。
对于当前代码来说,里面的缓冲区就不属于数组 result,它属于 C 数组。因此 result 在被回收的时候,是不会管这个缓冲区的;但我们通过 PyArray_ENABLEFLAGS 将它的 flags 设置成了 NPY_ARRAY_OWNDATA,让它拥有了缓冲区的所有权。然后当 Numpy 数组被回收时,也会将缓冲区给回收掉,对于当前的例子而言,表现就是 Numpy 数组回收时,C 数组也被回收了(或者说内存被释放了),避免了内存泄漏。
编译测试一下:
import cython_test arr = cython_test.make_matrix(3, 3) print(arr.__class__) """ <class 'numpy.ndarray'> """ print(arr) """ [[0. 0. 0.] [0. 0. 0.] [0. 0. 0.]] """ arr[1, 1] = 111 print(arr) """ [[ 0. 0. 0.] [ 0. 111. 0.] [ 0. 0. 0.]] """
一切正常,并且此时不会出现内存泄漏。
基于缓冲区所有权引发的一些思考
我们上面的例子中,Numpy 数组在创建的时候直接使用了 C 数组作为自己的缓冲区,然后通过 PyArray_ENABLEFLAGS 让 Numpy 数组具有对缓冲区的所有权。这样在释放 Numpy 数组的时候,同时也会释放缓冲区(即 C 数组所占内存)。
所以看下面一段代码:
cimport numpy as c_np import numpy as np cdef extern from "numpy/ndarraytypes.h": void PyArray_ENABLEFLAGS(c_np.PyArrayObject *arr, int flags) b = b"hello world" arr = np.frombuffer(b, dtype="S1") PyArray_ENABLEFLAGS(<c_np.PyArrayObject *>arr, c_np.NPY_ARRAY_OWNDATA) del arr print(b)
你觉得上面代码在执行时会发生什么结果呢?很明显,解释器会异常崩溃。原因是数组 arr 使用的缓冲区不是它自己的,缓冲区是 bytes 对象的,但它获取了缓冲区的所有权。所以 del arr 之后,释放的不只是 Numpy 数组,还有它内部的缓冲区。
而 b 和 arr 共用一个缓冲区,并且 del arr 会将缓冲区也释放掉,那么再打印 b 会有什么后果呢?毫无疑问,解释器直接崩溃挂掉。因此当 Numpy 数组需要拥有所有权时,缓冲区基本都是来自堆区的 C 数组。
通过包装器实现堆区 C 数组的释放
上面释放 C 数组的方式可以说非常的优雅,但它使用了 Numpy 提供的 C API,而如果我们事先不知道这个 API 的话,那么能不能用其它的方式实现呢?
Linux 之父说过:一层架构搞不定,那就再套一层。我们这里的做法与之类似,只需要再定义一个包装器即可。
from libc.stdlib cimport free cimport numpy as c_np import numpy as np cdef extern from "heap_malloc.h": float *make_matrix_c(int nrows, int ncols) cdef class ArrayResult: # 堆区的 C 指针 cdef void *_ptr # 转成 array 让外界访问 cdef public c_np.ndarray array def __dealloc__(self): if self._ptr != NULL: free(self._ptr) def make_matrix(int nrows, int ncols): cdef float *arr = make_matrix_c(nrows, ncols) cdef float[:, ::1] m = <float [:nrows, :ncols]>arr m[...] = 0 # 创建一个 ArrayResult 实例对象充当包装器 cdef ArrayResult art = ArrayResult() # 设置指针和数组 art._ptr = <void *> arr art.array = np.asarray(m) # 然后将 ArrayResult 实例对象返回 return art
编译测试一下:
import cython_test art = cython_test.make_matrix(3, 3) array = art.array print(array) """ [[0. 0. 0.] [0. 0. 0.] [0. 0. 0.]] """ array[0, 1] = 111 print(array) """ [[ 0. 111. 0.] [ 0. 0. 0.] [ 0. 0. 0.]] """ # 删除 art,会执行 __dealloc__ 方法 # 在内部会将堆区的 C 数组释放掉 del art # 此时 array 就不能再用了 # 虽然这里打印没有报错,但内存已经被回收了 # 所以打印出来的就是乱七八糟的脏数据 print(array) """ [[-4.1069705e-16 7.1694633e-41 2.8628528e-42] [ 0.0000000e+00 0.0000000e+00 0.0000000e+00] [ 0.0000000e+00 0.0000000e+00 0.0000000e+00]] """
以上我们就实现了 Numpy 数组和 C 数组共享内存,当然这并不复杂,重点是 C 数组要如何释放?总共有三种方式:
1)每次创建 C 数组时,就用一个全局变量将其指针保存起来,然后再单独定义一个函数用于释放。但这种方式有一个很大的弊端,就是不能同时保存多个,在申请第二个 C 数组之前,必须先把第一个 C 数组释放掉。
2)让 Numpy 数组拥有缓冲区(C 数组)的所有权,这样在 Numpy 数组被回收时,C 数组也会被自动释放掉。该方式最优雅,但它需要用到 Numpy 提供的 C API,而你已经知道了这个 API,那么在工作中推荐使用第二种方式。
3)将 Numpy 数组和 C 数组封装起来,变成某个实例对象的两个属性,然后将 C 数组的释放逻辑写在 __dealloc__ 方法中。这样外界便可以通过该对象拿到想要的结果,并且也能保证 C 数组能够回收。
当然啦,最方便也是最稳妥的方式还是将数据拷贝一份。如果引入 C 只是为了快速计算,但返回的 C 数组不是很大,那么将数据拷贝一份也是个不错的选择。至于具体怎么做,就取决于你的业务需求。
小结
到目前为止,我们介绍了 Python 的各种可以转成类型化 memoryview 的对象,但是 Numpy array 绝对最具普遍性、灵活性以及表现力。而且除了 Python 对象之外,类型化 memoryview 还可以使用 C 级数组,不管是栈上分配,还是堆上分配。
Cython 的类型化 memoryview 的核心就在于,它提供了一个一致的抽象,这个抽象适用于所有支持缓冲区协议的对象。此外,它还为我们提供了高效的 C 级访问缓冲区,并且共享内存。