45、Python与C交互:从代码封装到性能优化的全面指南

Python与C交互:从代码封装到性能优化的全面指南

在Python开发中,有时需要与C代码进行交互,以利用C语言的高性能或使用现有的C库。本文将详细介绍如何使用Cython、Swig等工具将现有的C代码封装为Python扩展模块,以及如何处理与C代码交互时的各种问题,如字符串传递、文件操作等。

1. Swig与类型映射

Swig是一个强大的工具,可用于将C代码封装为Python扩展模块。类型映射(typemap)是Swig中一个高级特性,它是应用于特定输入参数模式的规则。例如,对于参数模式 (double *a, int n) ,类型映射是一段C代码,它告诉Swig如何将Python对象转换为关联的C参数。

在类型映射代码中, $1 $2 等替换符引用包含转换为类型映射模式的C参数值的变量。 $input 引用作为输入参数提供的 PyObject * $argnum 是参数编号。

虽然Swig功能强大,但编写和理解类型映射是使用Swig的程序员面临的最困难任务之一。需要理解Python C API的复杂细节以及Swig与它的交互方式。不过,如果有大量C代码需要作为扩展模块使用,Swig将是一个非常强大的工具。

2. 使用Cython封装现有C代码
2.1 问题与解决方案概述

当希望使用Cython让Python扩展模块封装现有的C库时,可以按照以下步骤进行:创建Cython模块扩展类似于创建自定义扩展,需要创建一组包装函数,但代码更像Python代码。

2.2 创建 csample.pxd 文件

首先,创建 csample.pxd 文件,它类似于C语言中的头文件,用于声明外部C函数和结构体:

# csample.pxd
# 
# 声明 "外部" C函数和结构体
cdef extern from "sample.h":
    int gcd(int, int)
    bint in_mandel(double, double, int)
    int divide(int, int, int *)
    double avg(double *, int) nogil

    ctypedef struct Point:
        double x
        double y

    double distance(Point *, Point *)

这个文件的名称必须是 csample.pxd ,而不是 sample.pxd ,这一点很重要。

2.3 创建 sample.pyx 文件

接着,创建 sample.pyx 文件,它定义了将Python解释器与 csample.pxd 中定义的底层C代码连接起来的包装函数:

# sample.pyx

# 导入底层C声明
cimport csample

# 从Python和C标准库导入一些功能
from cpython.pycapsule cimport *
from libc.stdlib cimport malloc, free

# 包装函数
def gcd(unsigned int x, unsigned int y):
    return csample.gcd(x, y)

def in_mandel(x, y, unsigned int n):
    return csample.in_mandel(x, y, n)

def divide(x, y):
    cdef int rem
    quot = csample.divide(x, y, &rem)
    return quot, rem

def avg(double[:] a):
    cdef:
        int sz
        double result

    sz = a.size
    with nogil:
        result = csample.avg(<double *> &a[0], sz)
    return result

# 用于清理Point对象的析构函数
cdef del_Point(object obj):
    pt = <csample.Point *> PyCapsule_GetPointer(obj,"Point")
    free(<void *> pt)

# 创建Point对象并以胶囊形式返回
def Point(double x,double y):
    cdef csample.Point *p
    p = <csample.Point *> malloc(sizeof(csample.Point))
    if p == NULL:
        raise MemoryError("No memory to make a Point")
    p.x = x
    p.y = y
    return PyCapsule_New(<void *>p,"Point",<PyCapsule_Destructor>del_Point)

def distance(p1, p2):
    pt1 = <csample.Point *> PyCapsule_GetPointer(p1,"Point")
    pt2 = <csample.Point *> PyCapsule_GetPointer(p2,"Point")
    return csample.distance(pt1,pt2)
2.4 创建 setup.py 文件

最后,创建 setup.py 文件来构建扩展模块:

from distutils.core import setup
from distutils.extension import Extension
from Cython.Distutils import build_ext

ext_modules = [
    Extension('sample',
              ['sample.pyx'],
              libraries=['sample'],
              library_dirs=['.'])]
setup(
  name = 'Sample extension module',
  cmdclass = {'build_ext': build_ext},
  ext_modules = ext_modules
)

运行以下命令来构建模块:

bash % python3 setup.py build_ext --inplace

如果一切正常,将得到一个 sample.so 扩展模块,可以按以下方式使用:

>>> import sample
>>> sample.gcd(42,10)
2
>>> sample.in_mandel(1,1,400)
False
>>> sample.in_mandel(0,0,400)
True
>>> sample.divide(42,10)
(4, 2)
>>> import array
>>> a = array.array('d',[1,2,3])
>>> sample.avg(a)
2.0
>>> p1 = sample.Point(2,3)
>>> p2 = sample.Point(4,5)
>>> p1
<capsule object "Point" at 0x1005d1e70>
>>> p2
<capsule object "Point" at 0x1005d1ea0>
>>> sample.distance(p1,p2)
2.8284271247461903
2.5 Cython包装的详细讨论
  • Cython的工作模式 :Cython的使用基于C模型。 .pxd 文件包含C定义(类似于 .h 文件), .pyx 文件包含实现(类似于 .c 文件)。 cimport 用于从 .pxd 文件导入定义,与普通的 import 不同,后者用于加载普通的Python模块。
  • 简单函数包装 :对于简单函数,Cython会生成包装代码,正确转换参数和返回值。为参数附加C数据类型是可选的,但这样可以获得额外的错误检查。例如,对于 gcd 函数,如果传入负值,会抛出异常:
>>> sample.gcd(-10,2)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "sample.pyx", line 7, in sample.gcd (sample.c:1284)
    def gcd(unsigned int x,unsigned int y):
OverflowError: can't convert negative value to unsigned int

还可以添加额外的检查代码:

def gcd(unsigned int x, unsigned int y):
    if x <= 0:
        raise ValueError("x must be > 0")
    if y <= 0:
        raise ValueError("y must be > 0")
    return csample.gcd(x,y)
  • 布尔值处理 :在 csample.pxd 文件中, in_mandel 函数返回 bint 而不是 int ,这会使函数从结果中创建正确的布尔值, 0 映射为 False 1 映射为 True
  • C数据类型声明 :在Cython包装函数中,可以声明C数据类型。例如, divide 函数中, rem 变量被声明为C的 int 类型, &rem 创建指向它的指针。
def divide(x,y):
    cdef int rem
    quot = csample.divide(x,y,&rem)
    return quot, rem
  • 内存视图的使用 avg 函数展示了Cython的一些高级功能。 def avg(double[:] a) 声明 avg 函数接受一个一维的 double 类型的内存视图。该函数可以接受任何兼容的数组对象,包括NumPy数组。
>>> import array
>>> a = array.array('d',[1,2,3])
>>> import numpy
>>> b = numpy.array([1., 2., 3.])
>>> import sample
>>> sample.avg(a)
2.0
>>> sample.avg(b)
2.0

在包装函数中, a.size &a[0] 分别引用数组的元素数量和底层指针。使用 <double *> &a[0] 可以将指针转换为正确的类型。此外, avg 函数使用 with nogil: 声明代码块在无全局解释器锁(GIL)的情况下执行,前提是外部函数明确声明可以在无GIL的情况下执行。
- Point 结构体的处理 :对于 Point 结构体,可以将其作为不透明指针处理,使用胶囊对象。也可以定义扩展类型,使代码更自然:

# sample.pyx

cimport csample
from libc.stdlib cimport malloc, free

cdef class Point:
    cdef csample.Point *_c_point
    def __cinit__(self, double x, double y):
        self._c_point = <csample.Point *> malloc(sizeof(csample.Point))
        self._c_point.x = x
        self._c_point.y = y

    def __dealloc__(self):
        free(self._c_point)

    property x:
        def __get__(self):
            return self._c_point.x
        def __set__(self, value):
            self._c_point.x = value

    property y:
        def __get__(self):
            return self._c_point.y
        def __set__(self, value):
            self._c_point.y = value

def distance(Point p1, Point p2):
    return csample.distance(p1._c_point, p2._c_point)

使用扩展类型后,操作 Point 对象更方便:

>>> import sample
>>> p1 = sample.Point(2,3)
>>> p2 = sample.Point(4,5)
>>> p1
<sample.Point object at 0x100447288>
>>> p2
<sample.Point object at 0x1004472a0>
>>> p1.x
2.0
>>> p1.y
3.0
>>> sample.distance(p1,p2)
2.8284271247461903
3. 使用Cython进行高性能数组操作
3.1 问题与解决方案

当需要编写高性能的数组处理函数以处理NumPy等库的数组时,可以使用Cython。以下是一个Cython函数示例,用于从一维双精度数组中裁剪值:

# sample.pyx (Cython)

cimport cython

@cython.boundscheck(False)
@cython.wraparound(False)
cpdef clip(double[:] a, double min, double max, double[:] out):
    '''
    裁剪a中的值,使其介于min和max之间。结果存储在out中
    '''
    if min > max:
        raise ValueError("min must be <= max")
    if a.shape[0] != out.shape[0]:
        raise ValueError("input and output arrays must be the same size")
    for i in range(a.shape[0]):
        if a[i] < min:
            out[i] = min
        elif a[i] > max:
            out[i] = max
        else:
            out[i] = a[i]

使用以下 setup.py 文件进行编译和构建:

from distutils.core import setup
from distutils.extension import Extension
from Cython.Distutils import build_ext

ext_modules = [
    Extension('sample',
              ['sample.pyx'])
]

setup(
  name = 'Sample app',
  cmdclass = {'build_ext': build_ext},
  ext_modules = ext_modules
)

运行 python3 setup.py build_ext --inplace 进行构建。

3.2 函数测试与性能比较

构建后的函数可以处理多种类型的数组对象:

# 示例:使用array模块
>>> import sample
>>> import array
>>> a = array.array('d',[1,-3,4,7,2,0])
>>> a
array('d', [1.0, -3.0, 4.0, 7.0, 2.0, 0.0])
>>> sample.clip(a,1,4,a)
>>> a
array('d', [1.0, 1.0, 4.0, 4.0, 2.0, 1.0])

# 示例:使用numpy
>>> import numpy
>>> b = numpy.random.uniform(-10,10,size=1000000)
>>> b
array([-9.55546017, 7.45599334, 0.69248932, ..., 0.69583148,
       -3.86290931, 2.37266888])
>>> c = numpy.zeros_like(b)
>>> c
array([ 0., 0., 0., ..., 0., 0., 0.])
>>> sample.clip(b,-5,5,c)
>>> c
array([-5.          , 5.        , 0.69248932, ..., 0.69583148,
       -3.86290931, 2.37266888])
>>> min(c)
-5.0
>>> max(c)
5.0

性能比较显示,Cython实现的 clip 函数比NumPy的 clip 函数略快:

>>> timeit('numpy.clip(b,-5,5,c)','from __main__ import b,c,numpy',number=1000)
8.093049556000551
>>> timeit('sample.clip(b,-5,5,c)','from __main__ import b,c,sample',
...         number=1000)
3.760528204000366
3.3 详细讨论
  • 类型化内存视图 :该配方使用Cython的类型化内存视图,大大简化了数组操作的代码。 cpdef clip() 声明 clip 函数既是C级函数,也是Python级函数,在Cython中调用更高效。
  • 输入输出参数 double[:] a double[:] out 声明为一维双精度数组。作为输入,它们可以访问任何正确实现内存视图接口的数组对象。编写生成数组结果的代码时,应遵循输出参数约定,将创建输出数组的责任交给调用者。
  • 性能优化 @cython.boundscheck(False) @cython.wraparound(False) 装饰器是可选的性能优化。前者移除数组边界检查,后者移除负索引处理。添加这些装饰器可以显著提高代码执行速度。
  • 算法优化 :优化算法也可以提高性能。例如,使用条件表达式重写 clip 函数:
@cython.boundscheck(False)
@cython.wraparound(False)
cpdef clip(double[:] a, double min, double max, double[:] out):
    if min > max:
        raise ValueError("min must be <= max")
    if a.shape[0] != out.shape[0]:
        raise ValueError("input and output arrays must be the same size")
    for i in range(a.shape[0]):
        out[i] = (a[i] if a[i] < max else max) if a[i] > min else min

测试表明,该版本代码快50%。
- GIL释放与多维数组处理 :对于某些数组操作,可以释放GIL以实现多线程并行工作。修改代码添加 with nogil: 语句即可。处理二维数组的 clip2d 函数示例如下:

@cython.boundscheck(False)
@cython.wraparound(False)
cpdef clip2d(double[:,:] a, double min, double max, double[:,:] out):
    if min > max:
        raise ValueError("min must be <= max")
    for n in range(a.ndim):
        if a.shape[n] != out.shape[n]:
            raise TypeError("a and out have different shapes")
    for i in range(a.shape[0]):
        for j in range(a.shape[1]):
            if a[i,j] < min:
                out[i,j] = min
            elif a[i,j] > max:
                out[i,j] = max
            else:
                out[i,j] = a[i,j]
4. 将函数指针转换为可调用对象

当有一个编译后的函数在内存中的地址,想将其转换为Python可调用对象时,可以使用 ctypes 模块。以下是一个示例,将C库中 sin 函数的地址转换为可调用对象:

>>> import ctypes
>>> lib = ctypes.cdll.LoadLibrary(None)
>>> # 获取C数学库中sin()的地址
>>> addr = ctypes.cast(lib.sin, ctypes.c_void_p).value
>>> addr
140735505915760
>>> # 将地址转换为可调用函数
>>> functype = ctypes.CFUNCTYPE(ctypes.c_double, ctypes.c_double)
>>> func = functype(addr)
>>> func
<CFunctionType object at 0x1006816d0>
>>> # 调用函数
>>> func(2)
0.9092974268256817
>>> func(0)
0.0

创建可调用对象时,需要创建 CFUNCTYPE 实例,第一个参数是返回对象的类型,后续参数是参数类型。将其围绕内存中的整数值地址包装,即可创建可调用对象。

在一些使用即时编译等高级代码生成技术的程序和库中,这种方法很有用。例如,使用 llvmpy 扩展创建一个简单的复合函数,并将其函数指针转换为Python可调用对象:

>>> from llvm.core import Module, Function, Type, Builder
>>> mod = Module.new('example')
>>> f = Function.new(mod,Type.function(Type.double(),
                     [Type.double(), Type.double()], False), 'foo')
>>> block = f.append_basic_block('entry')
>>> builder = Builder.new(block)
>>> x2 = builder.fmul(f.args[0],f.args[0])
>>> y2 = builder.fmul(f.args[1],f.args[1])
>>> r = builder.fadd(x2,y2)
>>> builder.ret(r)
<llvm.core.Instruction object at 0x10078e990>
>>> from llvm.ee import ExecutionEngine
>>> engine = ExecutionEngine.new(mod)
>>> ptr = engine.get_pointer_to_function(f)
>>> ptr
4325863440
>>> foo = ctypes.CFUNCTYPE(ctypes.c_double, ctypes.c_double, ctypes.c_double)(ptr)
>>> # 调用函数
>>> foo(2,3)
13.0
>>> foo(4,5)
41.0
>>> foo(1,2)
5.0

需要注意的是,在这个级别上的任何错误都可能导致Python解释器崩溃,因为是直接操作计算机的内存地址和本机机器代码。

5. 向C库传递带空字符的字符串

当编写Python扩展模块需要向C库传递以空字符结尾(NULL-terminated)的字符串时,可以采用以下方法。考虑以下C函数用于演示和测试:

void print_chars(char *s) {
    while (*s) {
        printf("%2x ", (unsigned char) *s);
        s++;
    }
    printf("\n");
}

该函数输出字符串中每个字符的十六进制表示,方便调试。例如:

print_chars("Hello");    // 输出: 48 65 6c 6c 6f

从Python调用该C函数有几种方式:
- 仅处理字节 :使用 PyArg_ParseTuple() 中的 "y" 转换代码,限制函数仅处理字节:

static PyObject *py_print_chars(PyObject *self, PyObject *args) {
    char *s;

    if (!PyArg_ParseTuple(args, "y", &s)) {
        return NULL;
    }
    print_chars(s);
    Py_RETURN_NONE;
}

这种方式会拒绝包含空字节的字节串和Unicode字符串:

>>> print_chars(b'Hello World')
48 65 6c 6c 6f 20 57 6f 72 6c 64
>>> print_chars(b'Hello\x00World')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: must be bytes without null bytes, not bytes
>>> print_chars('Hello World')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'str' does not support the buffer interface
  • 处理Unicode字符串 :使用 PyArg_ParseTuple() 中的 "s" 格式化代码,可自动将字符串转换为UTF-8编码的以空字符结尾的字符串:
static PyObject *py_print_chars(PyObject *self, PyObject *args) {
    char *s;

    if (!PyArg_ParseTuple(args, "s", &s)) {
        return NULL;
    }
    print_chars(s);
    Py_RETURN_NONE;
}

示例如下:

>>> print_chars('Hello World')
48 65 6c 6c 6f 20 57 6f 72 6c 64
>>> print_chars('Spicy Jalapeño') # 注意: UTF-8编码
53 70 69 63 79 20 4a 61 6c 61 70 65 c3 b1 6f
>>> print_chars('Hello\x00World')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: must be str without null characters, not str
>>> print_chars(b'Hello World')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: must be str, not bytes
  • 直接处理 PyObject * :如果无法使用 PyArg_ParseTuple() ,可以手动检查和提取 char * 引用:
/* 假设已经有一个Python对象 */
PyObject *obj;

/* 从字节转换 */
{
    char *s;
    s = PyBytes_AsString(o);
    if (!s) {
        return NULL;  /* 已经抛出TypeError */
    }
    print_chars(s);
}

/* 从字符串转换为UTF-8字节 */
{
    PyObject *bytes;
    char *s;
    if (!PyUnicode_Check(obj)) {
        PyErr_SetString(PyExc_TypeError, "Expected string");
        return NULL;
    }
    bytes = PyUnicode_AsUTF8String(obj);
    s = PyBytes_AsString(bytes);
    print_chars(s);
    Py_DECREF(bytes);
}

这两种转换都能保证得到以空字符结尾的数据,但不会检查字符串中其他位置是否有空字节,如有需要需手动检查。

使用 PyArg_ParseTuple() 中的 "s" 格式化代码时,会有隐藏的内存重用问题。当使用这种转换时,会创建一个UTF-8字符串并永久附加到原始字符串对象上。如果原始字符串包含非ASCII字符,会增加字符串的大小,直到被垃圾回收。例如:

>>> import sys
>>> s = 'Spicy Jalapeño'
>>> sys.getsizeof(s)
87
>>> print_chars(s)      # 传递字符串
53 70 69 63 79 20 4a 61 6c 61 70 65 c3 b1 6f
>>> sys.getsizeof(s)    # 注意: 大小增加了
103

如果内存增长是个问题,可以使用 PyUnicode_AsUTF8String() 重写扩展代码:

static PyObject *py_print_chars(PyObject *self, PyObject *args) {
    PyObject *o, *bytes;
    char *s;

    if (!PyArg_ParseTuple(args, "U", &o)) {
        return NULL;
    }
    bytes = PyUnicode_AsUTF8String(o);
    s = PyBytes_AsString(bytes);
    print_chars(s);
    Py_DECREF(bytes);
    Py_RETURN_NONE;
}

修改后,使用时不会增加原始字符串的大小:

>>> import sys
>>> s = 'Spicy Jalapeño'
>>> sys.getsizeof(s)
87
>>> print_chars(s)
53 70 69 63 79 20 4a 61 6c 61 70 65 c3 b1 6f
>>> sys.getsizeof(s)
87

如果使用 ctypes 调用C函数, ctypes 只接受字节,且不检查插入的空字节:

>>> import ctypes
>>> lib = ctypes.cdll.LoadLibrary("./libsample.so")
>>> print_chars = lib.print_chars
>>> print_chars.argtypes = (ctypes.c_char_p,)
>>> print_chars(b'Hello World')
48 65 6c 6c 6f 20 57 6f 72 6c 64
>>> print_chars(b'Hello\x00World')
48 65 6c 6c 6f
>>> print_chars('Hello World')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ctypes.ArgumentError: argument 1: <class 'TypeError'>: wrong type

如果要传递字符串而不是字节,需要手动编码为UTF-8:

>>> print_chars('Hello World'.encode('utf-8'))
48 65 6c 6c 6f 20 57 6f 72 6c 64

对于其他扩展工具(如Swig、Cython),如果要用于向C代码传递字符串,需要仔细研究相关问题。

6. 向C库传递Unicode字符串

当编写Python扩展模块需要向C库函数传递Python字符串,而C库可能无法正确处理Unicode时,需要将Python字符串转换为C库容易理解的形式。

考虑以下两个C函数用于演示和实验:

void print_chars(char *s, int len) {
    int n = 0;
    while (n < len) {
        printf("%2x ", (unsigned char) s[n]);
        n++;
    }
    printf("\n");
}

void print_wchars(wchar_t *s, int len) {
    int n = 0;
    while (n < len) {
        printf("%x ", s[n]);
        n++;
    }
    printf("\n");
}
  • 处理字节导向的函数 :对于 print_chars 函数,需要将Python字符串转换为合适的字节编码,如UTF-8。以下是一个扩展函数示例:
static PyObject *py_print_chars(PyObject *self, PyObject *args) {
    char *s;
    Py_ssize_t len;

    if (!PyArg_ParseTuple(args, "s#", &s, &len)) {
        return NULL;
    }
    print_chars(s, len);
    Py_RETURN_NONE;
}
  • 处理宽字符函数 :对于处理 wchar_t 类型的C库函数,可以编写如下扩展代码:
static PyObject *py_print_wchars(PyObject *self, PyObject *args) {
    wchar_t *s;
    Py_ssize_t len;

    if (!PyArg_ParseTuple(args, "u#", &s, &len)) {
        return NULL;
    }
    print_wchars(s,len);
    Py_RETURN_NONE;
}

以下是这些函数的交互示例:

>>> s = 'Spicy Jalapeño'
>>> print_chars(s)
53 70 69 63 79 20 4a 61 6c 61 70 65 c3 b1 6f
>>> print_wchars(s)
53 70 69 63 79 20 4a 61 6c 61 70 65 f1 6f

可以看到, print_chars 函数接收UTF-8编码的数据,而 print_wchars 函数接收Unicode码点值。

在传递字符串时,需要考虑C库的性质。很多C库更适合接收字节而不是字符串。如果决定传递字符串,要知道Python 3使用自适应的字符串表示,不能直接映射到C库的 char * wchar_t * 类型。 PyArg_ParseTuple() s# u# 格式化代码可以安全地进行转换。

然而,这种转换可能会导致原始字符串对象的大小永久增加。例如:

>>> import sys
>>> s = 'Spicy Jalapeño'
>>> sys.getsizeof(s)
87
>>> print_chars(s)
53 70 69 63 79 20 4a 61 6c 61 70 65 c3 b1 6f
>>> sys.getsizeof(s)
103
>>> print_wchars(s)
53 70 69 63 79 20 4a 61 6c 61 70 65 f1 6f
>>> sys.getsizeof(s)
163

对于小规模的字符串数据,这可能不是问题,但对于大规模的文本处理,可能需要避免这种内存的低效使用。以下是一个避免内存问题的替代实现:

static PyObject *py_print_chars(PyObject *self, PyObject *args) {
    PyObject *obj, *bytes;
    char *s;
    Py_ssize_t   len;

    if (!PyArg_ParseTuple(args, "U", &obj)) {
        return NULL;
    }
    bytes = PyUnicode_AsUTF8String(obj);
    PyBytes_AsStringAndSize(bytes, &s, &len);
    print_chars(s, len);
    Py_DECREF(bytes);
    Py_RETURN_NONE;
}

避免在处理 wchar_t 时的内存重用更困难。Python内部使用最有效的表示方式存储字符串,没有统一的数据表示,不能简单地将内部数组转换为 wchar_t * PyArg_ParseTuple() "u#" 格式化代码会创建一个 wchar_t 数组并复制文本,但会降低效率并将结果附加到字符串对象上。

如果要避免这种长期的内存重用,可以将Unicode数据复制到临时数组,传递给C库函数,然后释放数组:

static PyObject *py_print_wchars(PyObject *self, PyObject *args) {
    PyObject *obj;
    wchar_t *s;
    Py_ssize_t len;

    if (!PyArg_ParseTuple(args, "U", &obj)) {
        return NULL;
    }
    if ((s = PyUnicode_AsWideCharString(obj, &len)) == NULL) {
        return NULL;
    }
    print_wchars(s, len);
    PyMem_Free(s);
    Py_RETURN_NONE;
}

在编写此代码时,该行为可能存在一个已知的bug。

如果知道C库接受的字节编码不是UTF-8,可以使用以下扩展代码进行转换:

static PyObject *py_print_chars(PyObject *self, PyObject *args) {
    char *s = 0;
    int  len;
    if (!PyArg_ParseTuple(args, "es#", "encoding-name", &s, &len)) {
        return NULL;
    }
    print_chars(s, len);
    PyMem_Free(s);
    Py_RETURN_NONE;
}

如果想直接处理Unicode字符串中的字符,可以使用以下代码进行底层访问:

static PyObject *py_print_wchars(PyObject *self, PyObject *args) {
    PyObject *obj;
    int n, len;
    int kind;
    void *data;

    if (!PyArg_ParseTuple(args, "U", &obj)) {
        return NULL;
    }
    if (PyUnicode_READY(obj) < 0) {
        return NULL;
    }

    len = PyUnicode_GET_LENGTH(obj);
    kind = PyUnicode_KIND(obj);
    data = PyUnicode_DATA(obj);

    for (n = 0; n < len; n++) {
        Py_UCS4 ch = PyUnicode_READ(kind, data, n);
        printf("%x ", ch);
    }
    printf("\n");
    Py_RETURN_NONE;
}

在传递Unicode字符串时,尽量选择UTF-8编码,因为它的支持更广泛,错误更少,且更受解释器支持。同时,一定要阅读有关Unicode处理的文档。

7. 将C字符串转换为Python对象

将C字符串转换为Python对象时,需要根据C字符串的类型(普通字符串或宽字符串)和编码方式进行不同的处理。
- 普通C字符串 :对于以 char *, int 对表示的C字符串,若要将其表示为原始字节字符串,可以使用 Py_BuildValue()

char *s;    /* 指向C字符串数据的指针 */
int   len;  /* 数据长度 */

/* 创建字节对象 */
PyObject *obj = Py_BuildValue("y#", s, len);

若要创建Unicode字符串,且知道 s 指向的是UTF-8编码的数据,可以这样做:

PyObject *obj = Py_BuildValue("s#", s, len);

s 是其他已知编码的字符串,可以使用 PyUnicode_Decode()

PyObject *obj = PyUnicode_Decode(s, len, "encoding", "errors");

/* 示例 */
obj = PyUnicode_Decode(s, len, "latin-1", "strict");
obj = PyUnicode_Decode(s, len, "ascii", "ignore");
  • 宽字符串 :对于以 wchar_t *, len 对表示的宽字符串,可以使用 Py_BuildValue()
wchar_t *w;     /* 宽字符字符串 */
int len;        /* 长度 */

PyObject *obj = Py_BuildValue("u#", w, len);

也可以使用 PyUnicode_FromWideChar()

PyObject *obj = PyUnicode_FromWideChar(w, len);

对于宽字符串,不进行字符数据的解释,假设是原始的Unicode码点,直接转换为Python对象。

将C字符串转换为Python对象时,遵循输入输出的原则,C数据必须根据某种编解码器显式解码为字符串。常见的编码包括ASCII、Latin-1和UTF-8。如果不确定编码或处理二进制数据,最好编码为字节而不是字符串。

创建Python对象时,会复制提供的字符串数据。如果需要,可以在完成后释放C字符串。为了提高可靠性,应尽量使用指针和大小来创建字符串,而不是依赖以空字符结尾的数据。

8. 处理编码不确定的C字符串

在将字符串在C和Python之间来回转换时,可能会遇到C字符串编码不确定或未知的情况。例如,C字符串应该是UTF-8编码,但没有严格的强制要求。以下是处理这种情况的方法。

考虑以下C数据和函数:

/* 一些可疑的字符串数据(损坏的UTF-8) */
const char *sdata = "Spicy Jalapeño®";
int slen = 16;

/* 输出字符数据 */
void print_chars(char *s, int len) {
    int n = 0;
    while (n < len) {
        printf("%2x ", (unsigned char) s[n]);
        n++;
    }
    printf("\n");
}

在这个例子中, sdata 包含UTF-8和损坏的数据,但在C中调用 print_chars(sdata, slen) 可以正常工作。

现在,假设要将 sdata 的内容转换为Python字符串,并在之后通过扩展将该字符串传递给 print_chars 函数。可以这样实现:

/* 将C字符串返回给Python */
static PyObject *py_retstr(PyObject *self, PyObject *args) {
    if (!PyArg_ParseTuple(args, "")) {
        return NULL;
    }
    return PyUnicode_Decode(sdata, slen, "utf-8", "surrogateescape");
}

/* print_chars()函数的包装器 */
static PyObject *py_print_chars(PyObject *self, PyObject *args) {
    PyObject *obj, *bytes;
    char *s = 0;
    Py_ssize_t   len;

    if (!PyArg_ParseTuple(args, "U", &obj)) {
        return NULL;
    }

    if ((bytes = PyUnicode_AsEncodedString(obj,"utf-8","surrogateescape"))
         == NULL) {
        return NULL;
    }
    PyBytes_AsStringAndSize(bytes, &s, &len);
    print_chars(s, len);
    Py_DECREF(bytes);
    Py_RETURN_NONE;
}

从Python调用这些函数的结果如下:

>>> s = retstr()
>>> s
'Spicy Jalapeño\udcae'
>>> print_chars(s)
53 70 69 63 79 20 4a 61 6c 61 70 65 c3 b1 6f ae

可以看到,损坏的字符串可以无错误地编码为Python字符串,然后再传递回C,转换回与原始C字符串相同的字节串。

在Python扩展模块中处理字符串时,C字符串可能不遵循Python通常期望的严格Unicode编码/解码规则,因此可能会传递损坏的数据。常见的例子是与底层系统调用相关的C字符串,如文件名。

通常,Unicode错误使用特殊的错误处理规则(如 strict ignore replace 等)处理,但这些规则会不可逆地破坏原始字符串内容。例如:

>>> raw = b'Spicy Jalapeño®'
>>> raw.decode('utf-8','ignore')
'Spicy Jalapeño'
>>> raw.decode('utf-8','replace')
'Spicy Jalapeño?'

surrogateescape 错误处理规则会将所有无法解码的字节转换为代理对的下半部分( \udcXX ,其中 XX 是原始字节的值):

>>> raw.decode('utf-8','surrogateescape')
'Spicy Jalapeño\udcae'

虽然这种表示在技术上是无效的Unicode,但它的主要目的是允许损坏的字符串在C和Python之间传递而不丢失数据。当使用 surrogateescape 再次编码字符串时,代理字符会转换回原始字节:

>>> s = raw.decode('utf-8', 'surrogateescape')
>>> s.encode('utf-8','surrogateescape')
b'Spicy Jalapeño®'

一般来说,应尽量避免使用代理编码,使用正确的编码会使代码更可靠。但在无法控制数据编码,且不能忽略或替换坏数据的情况下,这种方法可以解决问题。许多Python的系统相关函数(如处理文件名、环境变量和命令行参数的函数)使用代理编码。

9. 向C扩展传递文件名

在编写Python扩展模块时,若需要向C库函数传递文件名,要确保文件名按照预期的系统文件名编码正确编码。
- 接收文件名的扩展函数 :可以使用以下代码编写一个接收文件名的扩展函数:

static PyObject *py_get_filename(PyObject *self, PyObject *args) {
    PyObject *bytes;
    char *filename;
    Py_ssize_t len;
    if (!PyArg_ParseTuple(args,"O&", PyUnicode_FSConverter, &bytes)) {
        return NULL;
    }
    PyBytes_AsStringAndSize(bytes, &filename, &len);
    /* 使用文件名 */
    ...

    /* 清理并返回 */
    Py_DECREF(bytes)
    Py_RETURN_NONE;
}
  • PyObject * 转换为文件名 :如果已经有一个 PyObject * 对象,要将其转换为文件名,可以这样做:
PyObject *obj;  /* 包含文件名的对象 */
PyObject *bytes;
char *filename;
Py_ssize_t len;

bytes = PyUnicode_EncodeFSDefault(obj);
PyBytes_AsStringAndSize(bytes, &filename, &len);
/* 使用文件名 */
...

/* 清理 */
Py_DECREF(bytes);
  • 将文件名转换回Python对象 :若需要将文件名转换回Python对象,可以使用以下代码:
/* 将文件名转换为Python对象 */

char *filename;      /* 已设置 */
int   filename_len;  /* 已设置 */

PyObject *obj = PyUnicode_DecodeFSDefaultAndSize(filename, filename_len);

以可移植的方式处理文件名是一个复杂的任务,将这个任务交给Python处理更好。如果在扩展代码中使用上述方法,文件名将按照Python的处理原则进行处理,包括字节编码/解码、处理不正确的字符、代理转义序列等问题。

10. 向C扩展传递打开的文件

当有一个Python中的打开文件对象,需要将其传递给C扩展代码使用时,可以按以下步骤操作。
- 将文件转换为整数文件描述符 :使用 PyObject_AsFileDescriptor() 将文件对象转换为整数文件描述符:

PyObject *fobj;     /* 以某种方式获得的文件对象 */
int fd = PyObject_AsFileDescriptor(fobj);
if (fd < 0) {
    return NULL;
}

这个文件描述符是通过调用 fobj 对象的 fileno() 方法获得的,因此可以与任何以兼容方式公开文件描述符的对象(如文件、套接字等)一起使用。获得文件描述符后,可以将其传递给各种可以处理文件的底层C函数。
- 将整数文件描述符转换回Python对象 :使用 PyFile_FromFd() 将整数文件描述符转换回Python对象:

int fd;     /* 现有的文件描述符(已打开) */
PyObject *fobj = PyFile_FromFd(fd, "filename","r",-1,NULL,NULL,NULL,1);

PyFile_FromFd() 的参数与 open() 函数类似, NULL 值表示使用参数 encoding errors newline 的默认值。

在将文件对象从Python传递到C时,需要注意以下几点:
- 缓冲区刷新 :Python通过 io 模块进行自己的输入输出缓冲。在将任何文件描述符传递给C之前,必须先刷新关联文件对象的输入输出缓冲区,否则文件流中的数据可能会出现无序的情况。
- 文件所有权和关闭 :要仔细跟踪文件的所有权,特别是文件的关闭操作。如果文件描述符传递给了C,但仍在Python中使用,要确保C不会意外关闭该文件。同样,如果将文件描述符转换为Python文件对象,要明确由谁负责关闭文件。 PyFile_FromFd() 的最后一个参数设置为 1 表示由Python负责关闭文件。
- 避免创建额外的缓冲区 :如果需要创建其他类型的文件对象,如使用 fdopen() 创建标准C库的 FILE * 对象,要特别小心。这样会在输入输出栈中创建两个完全独立的输入输出缓冲层(一个来自Python的 io 模块,另一个来自C的 stdio ),C中的 fclose() 操作可能会意外关闭文件,导致Python无法再使用。如果有选择,最好让扩展代码使用底层的整数文件描述符,而不是使用像 <stdio.h> 提供的高级抽象。

11. 从C读取类文件对象

当需要编写C扩展代码从任何Python类文件对象(如普通文件、 StringIO 对象等)中消费数据时,可以按以下步骤实现。
以下是一个C扩展函数示例,它从类文件对象中消费所有数据并将其输出到标准输出:

#define CHUNK_SIZE 8192
/* 消费类文件对象并将字节写入stdout */
static PyObject *py_consume_file(PyObject *self, PyObject *args) {
    PyObject *obj;
    PyObject *read_meth;
    PyObject *result = NULL;
    PyObject *read_args;

    if (!PyArg_ParseTuple(args,"O", &obj)) {
        return NULL;
    }

    /* 获取传递对象的读取方法 */
    if ((read_meth = PyObject_GetAttrString(obj, "read")) == NULL) {
        return NULL;
    }

    /* 构建read()的参数列表 */
    read_args = Py_BuildValue("(i)", CHUNK_SIZE);
    while (1) {
        PyObject *data;
        PyObject *enc_data;
        char *buf;
        Py_ssize_t len;

        /* 调用read() */
        if ((data = PyObject_Call(read_meth, read_args, NULL)) == NULL) {
            goto final;
        }
        /* 检查EOF */
        if (PySequence_Length(data) == 0) {
            Py_DECREF(data);
            break;
        }

        /* 将Unicode编码为C的字节 */
        if ((enc_data=PyUnicode_AsEncodedString(data,"utf-8","strict"))==NULL) {
            Py_DECREF(data);
            goto final;
        }

        /* 提取缓冲区数据 */
        PyBytes_AsStringAndSize(enc_data, &buf, &len);

        /* 写入stdout(可以替换为更有用的操作) */
        write(1, buf, len);

        /* 清理 */
        Py_DECREF(enc_data);
        Py_DECREF(data);
    }
    result = Py_BuildValue("");

    final:
        /* 清理 */
        Py_DECREF(read_meth);
        Py_DECREF(read_args);
        return result;
}

可以通过创建一个 StringIO 类文件对象并传递给该函数来测试代码:

>>> import io
>>> f = io.StringIO('Hello\nWorld\n')
>>> import sample
>>> sample.consume_file(f)
Hello
World

与普通系统文件不同,类文件对象不一定基于底层的文件描述符构建,因此不能使用普通的C库函数来访问它们。需要使用Python C API,就像在Python中操作类文件对象一样。

在解决方案中,首先从传递的对象中获取 read() 方法,构建参数列表并多次调用 PyObject_Call() 来调用该方法。使用 PySequence_Length() 检查返回结果的长度是否为0,以检测文件结束(EOF)。

在所有的输入输出操作中,要考虑底层的编码和字节与Unicode之间的区别。这个示例展示了如何以文本模式读取文件并将读取的文本解码为字节。

综上所述,在Python与C代码交互的过程中,涉及到代码封装、性能优化、字符串处理、文件操作等多个方面。通过合理使用Cython、Swig等工具,以及掌握Python C API的相关知识,可以高效地实现Python与C代码的交互,充分发挥两者的优势。在实际应用中,要根据具体需求选择合适的方法,并注意处理各种细节问题,如内存管理、编码转换等,以确保代码的正确性和性能。

Python与C交互:从代码封装到性能优化的全面指南

12. 关键技术点总结与对比

为了更清晰地展示不同场景下Python与C交互的方法,下面通过表格对前面介绍的关键技术点进行总结与对比:
| 场景 | 工具/方法 | 关键代码示例 | 优点 | 注意事项 |
| — | — | — | — | — |
| 封装现有C代码 | Cython | csample.pxd sample.pyx setup.py | 代码更像Python,便于编写;可利用C的高性能 | 需要理解Cython的工作模式和语法 |
| 高性能数组操作 | Cython | clip 函数 | 性能高,可处理多种数组对象 | 注意内存视图和GIL的使用 |
| 函数指针转换 | ctypes | CFUNCTYPE | 可将内存地址转换为可调用对象 | 操作底层内存,易导致解释器崩溃 |
| 传递带空字符的字符串 | PyArg_ParseTuple等 | "y" "s" 格式化代码 | 可处理字节和Unicode字符串 | 注意内存重用问题 |
| 传递Unicode字符串 | PyArg_ParseTuple等 | "s#" "u#" 格式化代码 | 可安全转换字符串 | 可能导致内存增加,处理 wchar_t 较复杂 |
| 转换C字符串为Python对象 | Py_BuildValue等 | "y#" "s#" 等 | 可根据编码转换字符串 | 注意编码选择和内存管理 |
| 处理编码不确定的C字符串 | surrogateescape | PyUnicode_Decode PyUnicode_AsEncodedString | 可无损传递损坏字符串 | 尽量避免使用,代码可靠性降低 |
| 传递文件名 | PyUnicode_FSConverter等 | py_get_filename 函数 | 按Python原则处理文件名 | 遵循相关函数的使用规则 |
| 传递打开的文件 | PyObject_AsFileDescriptor、PyFile_FromFd | | 可在Python和C之间传递文件 | 注意缓冲区刷新和文件所有权 |
| 读取类文件对象 | Python C API | py_consume_file 函数 | 可处理多种类文件对象 | 考虑编码和字节与Unicode的区别 |

13. 交互流程梳理

下面通过mermaid流程图展示Python与C交互的一般流程:

graph LR
    A[需求分析] --> B{选择工具}
    B -- Cython --> C[创建.pxd和.pyx文件]
    B -- Swig --> D[编写类型映射]
    B -- ctypes --> E[创建CFUNCTYPE实例]
    C --> F[创建setup.py文件并构建]
    D --> G[生成扩展模块]
    E --> H[将地址转换为可调用对象]
    F --> I[使用扩展模块]
    G --> I
    H --> I
    I --> J{处理数据类型}
    J -- 字符串 --> K[选择合适的字符串处理方法]
    J -- 文件 --> L[选择合适的文件处理方法]
    K --> M[进行编码转换和内存管理]
    L --> N[注意缓冲区和文件所有权]
    M --> O[完成交互]
    N --> O
14. 实际应用案例分析

假设我们正在开发一个图像处理应用,需要对大量的图像数据进行高性能处理。由于Python在处理大规模数据时性能可能不足,我们可以使用Cython将现有的C图像处理库封装为Python扩展模块。

14.1 封装C图像处理库

首先,创建 csample.pxd 文件声明C库中的函数:

# csample.pxd
cdef extern from "image_processing.h":
    void process_image(unsigned char *image, int width, int height)

然后,创建 sample.pyx 文件编写包装函数:

# sample.pyx
cimport csample
import numpy as np

def process_image_wrapper(np.ndarray[np.uint8_t, ndim=2] image):
    width = image.shape[1]
    height = image.shape[0]
    csample.process_image(<unsigned char *>image.data, width, height)
    return image

最后,创建 setup.py 文件进行构建:

from distutils.core import setup
from distutils.extension import Extension
from Cython.Distutils import build_ext

ext_modules = [
    Extension('sample',
              ['sample.pyx'],
              libraries=['image_processing'],
              library_dirs=['.'])]
setup(
    name='Image processing extension module',
    cmdclass={'build_ext': build_ext},
    ext_modules=ext_modules
)
14.2 使用扩展模块

在Python代码中使用封装好的扩展模块:

import numpy as np
import sample

# 生成随机图像数据
image = np.random.randint(0, 255, size=(100, 100), dtype=np.uint8)

# 调用扩展模块进行图像处理
processed_image = sample.process_image_wrapper(image)
14.3 性能对比

为了验证封装后的性能提升,我们可以与纯Python实现的图像处理函数进行对比:

import timeit

# 纯Python实现的图像处理函数
def process_image_python(image):
    width = image.shape[1]
    height = image.shape[0]
    for i in range(height):
        for j in range(width):
            # 简单的图像处理操作
            image[i, j] = image[i, j] * 2
    return image

# 性能测试
t1 = timeit.timeit(lambda: sample.process_image_wrapper(image), number=100)
t2 = timeit.timeit(lambda: process_image_python(image), number=100)

print(f"Cython扩展模块处理时间: {t1} 秒")
print(f"纯Python函数处理时间: {t2} 秒")

通过性能测试可以发现,使用Cython封装的扩展模块处理图像数据的速度明显快于纯Python函数。

15. 未来发展趋势与展望

随着Python在数据科学、人工智能等领域的广泛应用,Python与C交互的需求将越来越大。未来,可能会有以下发展趋势:
- 工具的进一步优化 :Cython、Swig等工具可能会不断优化,提供更简洁、高效的代码封装方式,降低开发者的学习成本。
- 更智能的类型映射 :在处理复杂数据类型时,工具可能会提供更智能的类型映射机制,自动处理数据转换和内存管理。
- 与新兴技术的结合 :随着即时编译(JIT)、深度学习框架等新兴技术的发展,Python与C交互可能会与这些技术更好地结合,提供更强大的功能。
- 跨平台兼容性增强 :确保在不同操作系统和硬件平台上,Python与C交互的代码都能稳定运行。

16. 总结与建议

在Python与C交互的过程中,我们可以充分发挥Python的灵活性和C的高性能优势。以下是一些总结和建议:
- 选择合适的工具 :根据具体需求选择Cython、Swig、ctypes等工具。如果代码更接近Python风格,Cython是一个不错的选择;如果需要处理复杂的类型映射,Swig可能更合适。
- 注意内存管理 :在传递数据和处理对象时,要注意内存的分配和释放,避免内存泄漏。
- 处理好编码问题 :字符串的编码转换是一个容易出错的地方,要根据实际情况选择合适的编码方式,并注意处理编码不确定的情况。
- 进行性能测试 :在实际应用中,要对不同的实现方式进行性能测试,选择性能最优的方案。
- 学习Python C API :掌握Python C API的相关知识,有助于更好地实现Python与C的交互,处理各种复杂的情况。

总之,通过合理运用各种工具和方法,注意处理好各种细节问题,我们可以高效地实现Python与C的交互,为开发高性能的Python应用提供有力支持。在未来的开发中,我们应密切关注技术的发展趋势,不断学习和探索新的方法和技巧,以适应不断变化的需求。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值