使用 Cython 包装外部的 C 代码

简介: 使用 Cython 包装外部的 C 代码


楔子




在前面的文章中我们知道了 Cython 如何通过提前编译的方式来对 Python 代码进行加速,本次我们聚焦在另一个方向上:假设有一个现成的 C 源文件,那么如何才能让 Python 访问它呢?

事实上,Python 访问 C 源文件,我在其它文章中介绍过。当时的方式是将 C 源文件编译成动态库,然后通过 Python 自带的 ctypes 模块来调用它,当然除了 ctypes,还有 swig、cffi 等专门的工具。而 Cython 也是支持我们访问 C 源文件的,只不过它是通过包装的方式让我们访问。

因为 Cython 同时理解 C 和 Python,所以它可以在 Python 语言和 C 语言结合的时候控制所有的方方面面,在完成这一壮举的同时,不仅保持了 Python 的风格,还使得 Cython 代码更加容易定位和调试。

如果做得好的话,那么生成的扩展将具有 C 级的性能、最小的包装开销,和一个友好的 Python 接口,我们也不需要怀疑正在使用的是包装的 C 代码。那么下面就来看看 Cython 如何包装 C 源文件。

要用 Cython 包装 C 源文件,我们必须在 Cython 中声明我们要使用的 C 组件的接口。为此,Cython 提供了一个 extern 语句,它的目的就是告诉 Cython,我们希望从指定的 C 头文件中使用 C 结构。语法如下:

cdef extern from "header_name":
    # 你希望使用哪些 C 结构, 那么就在这里声明
    # 如果不需要的话可以写上一个pass

我们知道 C 头文件存放声明,C 源文件存放实现。而在 C 语言中,如果一个源文件想使用另一个源文件定义的函数,那么只需要导入相应的头文件即可,会自动去源文件中寻找对应的实现。

比如在 a.c 中想使用 b.c 里面的一个函数,那么我们需要在 a.c 中 #include "b.h",然后就可以使用 b.c 里面的内容了。而对于当前的 Cython 也是同理,如果想要包装 C 源文件,那么也是要引入对应的头文件的,通过 cdef extern from 来引入。引入之后也可以在 Cython 里面直接使用,真的是非常方便,因为我们说 Cython 它同时理解 C 和 Python。此外 Cython 还会在编译时检查 C 的声明是否正确,如果不正确则出现编译错误。

下面我们就来详细介绍 cdef extern from 的用法。


声明外部的 C 函数以及给类型起别名




extern 块中最常见的声明是 C 函数和 typedef,这些声明几乎可以直接写在 Cython 中,只需要做一下修改。

1)将 typedef 变成 ctypedef;

2)删除类似于 restrict、volatile 等不必要、以及不支持的关键字;

3)确保函数的返回值类型和函数名在同一行;

// 在 C 中,可以这么写,但是 Cython 不行
// 因为 Cython 要求返回值类型和函数名在同一行
int 
foo(){
    return 123
}

4)删除行尾的分号;

下面我们定义一个 C 的头文件:header.h,写上一些简单的 C 声明和宏。

#define M_PI 3.1415926
#define MAX(a, b) ((a) >= (b) ? (a) : (b))
double hypot(double, double);
typedef int integral;
typedef double real;
void func(integral, integral, real);
real *func_arrays(integral[], integral[][10], real **);

如果你想在 Cython 中使用的话,那么就把那些想用的写在 Cython 中,当然我们说不能直接照搬,因为 C 和 Cython 的声明还是有些略微的差异的,而差异就是上面说的那些。

cdef extern from "header.h":
    double M_PI
    double MAX(double a, double b)
    double hypot(double x, ‍double y)
    ctypedef int integral
    ctypedef double real
    void func(integral a, integral b, real c)
    real *func_arrays(integral[] i, integral[][10] j, real **k)

注意:M_PI 这个宏,我们根据值将其声明为 double 类型的变量;同理对于 MAX 宏也是如此,把它当成接收两个 double、返回一个 double 的函数。

另外在 extern 块的声明中,我们为函数参数添加了一个名字。这是推荐的,但并不是强制的;如果有参数名的话,那么可以通过关键字参数调用,对于接口的使用会更加明确。

然后还有一点需要注意,我们上面声明变量和函数的时候,没有使用 cdef。这是因为在 cdef extern from 语句块里面,cdef 可以省略掉,但在外部则不可以省略。

Cython 支持 C 中的所有声明,甚至复杂的函数指针也是可以的,比如:

cdef extern from "header2.h":
    ctypedef void (*void_int_fptr)(int)
    void_int_fptr signal(void_int_fptr)
    # 上面两行等价于 void (*signal(void(*)(int)))(int)

所以我们可以进行非常复杂的声明,当然日常也很少会用到。由于简单的类型声明,像数值、字符串、数组、指针、void 等等已经构成了 C 声明的大多数,因此很多时候我们直接将 C 中的声明复制粘贴过来,然后去掉分号就可以了。


声明并包装 C 结构体、共同体、枚举




关于结构体、共同体和枚举,前面已经介绍过了,这里以结构体为例,再结合头文件说一遍。

// header.h
struct Girl1 {
    char *name;
    int age;
};
typedef struct {
    char *name;
    int age;
} Girl2;

以上是一个 C 的头文件,我们在 Cython 中导入之后要怎么使用呢?

# cython_test.pyx
cdef extern from "header.h":
    # 定义结构体要和 C 保持一致
    # C 使用 struct Girl1{...},那么这里也是如此
    # 这里依旧省略了 cdef,写成 cdef struct Girl1 也可以
    struct Girl1:  
        char *name
        int age
    # C 使用 typedef struct {...} Girl2
    # 这里也要使用 ctypedef
    ctypedef struct Girl2: 
        char *name
        int age
# 对于结构体而言, 里面的成员只能用 C 中的类型
# 而创建结构体实例都是使用 "cdef 结构体类型 变量名 =  " 的方式
cdef Girl1 g1 = Girl1("komeiji satori", 16)
cdef Girl2 g2 = Girl2("komeiji koishi", age=16)
# 可以看到无论是 cdef struct 定义的
# 还是通过 ctypedef 起的类型别名, 使用方式没有任何区别
print(g1)
print(g2)

结构体的定义必须是完整的,里面的成员、类型都必须写清楚。所以结构体我们拿到 cdef extern from 外面定义也是可以的,只不过在外面定义的话,第一种定义方式前面的 cdef 关键字不可以省略。

然后我们来进行编译,当涉及到 C 文件时,需要采用手动编译的方式。

from distutils.core import setup, Extension
from Cython.Build import cythonize
# 我们只导入了一个头文件
ext = [Extension("cython_test",
                 sources=["cython_test.pyx"],
                 include_dirs=["."])]
setup(ext_modules=cythonize(ext, language_level=3))

里面的 include_dirs 表示头文件的搜索路径,header.h 位于当前目录。

导入测试:

import cython_test
"""
{'name': b'komeiji satori', 'age': 16}
{'name': b'komeiji koishi', 'age': 16}
"""

因为里面有 print 所以导入的时候自动打印了,我们看到 C 的结构体到 Python 中会变成字典。

注意:我们使用 cdef extern from 导入头文件的时候,代码块里的声明在 C 头文件中应该已经存在。假设我们还想通过 ctypedef 给 int 起一个别名,但这个逻辑在 C 的头文件中是不存在的,而是我们自己想这么做,那么这个逻辑就不应该放在 cdef extern from 块中,而是应该放在全局区域,否则是不起作用的。

cdef extern from 里面的类型别名、声明什么的,都是根据头文件来的,我们将头文件中想要使用的结构,放在 cdef extern from 中进行声明。而我们自己单独设置的声明(头文件中不存在的逻辑)应该放在外面。


包装 C 函数




在最开始介绍斐波那契数列的时候,我们已经演示过这种方式了,这里再来详细介绍一下。

// header.h
typedef struct {
    char *name;
    int age;
} Girl;
// 里面定义一个结构体类型 和 一个函数声明
char *return_info (Girl g);
// source.c
#include <stdio.h>
#include <stdlib.h>
#include "header.h"
char *return_info (Girl g) {
    // 堆区申请一块内存
    char *info = (char *)malloc(50);
    // 拷贝一个字符串进去
    sprintf(info, "name: %s, age: %d", g.name, g.age);
    // 返回指针
    return info;
}

然后在 Cython 里面调用:

from libc.stdlib cimport free
cdef extern from "header.h":
    # C 里面使用 typedef sturct {...} Girl
    # 那么这里也要使用 ctypedef
    ctypedef struct Girl:
        char *name
        int age
 
    # 声明函数时,可以省略 cdef
    # 当然也可以不省略
    char *return_info(Girl)
# cdef extern from 里面声明的是 C 函数
# 它类似于使用 cdef 关键字定义的函数,显然无法被外界访问
# 因此我们还需要定义一个包装器
cpdef bytes info(dict d):
    cdef:
        # 接收一个字典
        str name = d["name"]
        int age = d["age"]
    # 根据对应的值创建结构体实例, 但 name 需要转成 bytes 对象
    # 因为 char * 对应 Python 的 bytes 对象
    cdef Girl g = Girl(name=bytes(name, encoding="utf-8"), 
                       age=age)
    # 构造出结构体实例之后, 传到 C 的函数中
    # 得到返回值, 也就是字符串的首地址
    cdef char *p = return_info(g)
    # 这里需要先拷贝给 Python
    # 会根据 char *p 创建一个 bytes 对象, 然后让变量 res 指向
    # 至于为什么不直接返回 p, 是因为 p 是在堆区申请的, 我们需要将它释放掉
    cdef bytes res = p
    free(p)
    # 返回 res
    return res

然后来进行编译:

from distutils.core import setup, Extension
from Cython.Build import cythonize
ext = [Extension("cython_test",
                 sources=["cython_test.pyx", "source.c"],
                 include_dirs=["."])]
setup(ext_modules=cythonize(ext, language_level=3))

导入测试:

import cython_test
print(cython_test.info({"name": "satori", "age": 16}))
"""
b'name: satori, age: 16'
"""

我们看到整体没有任何问题,但很明显这个例子有点刻意了,故意兜这么一个圈子。但这么做主要是想介绍 C 和 Cython 之间的交互方式,以及 Cython 调用 C 库是有多么的方便。

当然我们还可以写一些更加复杂的逻辑,比如定义一个类,但这样也会带来一些方便之处,那就是 __dealloc__。我们把指向堆内存的指针的释放逻辑写在这里面,然后当对象被销毁时会自动调用。

另外 cdef extern from 除了可以引入 C 头文件之外,还可以引入 C 源文件:

// source.c
int func(int a, int b) {
    return a + b;
}

以上是一个 C 源文件,我们也是可以直接通过 cdef extern from 来引入的:

cdef extern from "source.c":
    # func 不能直接被 Python 调用,因为它是 C 的函数
    # 我们需要手动创建包装器
    int func(int a, int b)
def py_func(int a, int b):
    return func(a, b)

下面进行编译,并且当涉及到 C 文件时,我们需要手动编译。

from distutils.core import setup, Extension
from Cython.Build import cythonize
ext = [Extension("cython_test",
                 sources=["cython_test.pyx"])]
setup(ext_modules=cythonize(ext, language_level=3))

在sources参数里面只写了 cython_test.pyx,并没有写 source.c。原因是它已经在 pyx 文件中通过 cdef extern from 的方式引入了,如果这里再将其指定在 sources 参数中的话,那么相当于将 source.c 里面的内容写入了两遍,在编译的时候就会出现符号多重定义的错误。

但如果导入的是只存放声明的头文件,那么为了在编译的时候能找到具体的实现,就必须要在 sources 参数中指定 C 源文件,否则编译时会出现符号找不到的错误。

编译成扩展模块之后导入一下:

import cython_test
print(cython_test.py_func(22, 33))  # 55

我们看到是没有问题的,但规范的做法是头文件存放声明,源文件存放具体实现,然后 cdef extern from 导入头文件,编译时在 sources 参数中指定源文件。


头文件的包含




如果一个头文件包含了另一个头文件,比如:在 a.h 里面引入了 b.h 和 c.h,那我们只需要 cdef extern from "a.h" 即可,然后可以同时使用 a.h、b.h、c.h 里面的内容。

// a.h
#include "b.h"
#include "c.h"
#define ADD(x, y) ((x) + (y))
// b.h
#define SUB(x, y) ((x) - (y))
// c.h
#define MUL(x, y) ((x) * (y))

在头文件中定义了一个宏,而在 Cython 里面我们可以看成是一个函数,函数参数的类型可以是整型、浮点型,只要在 C 里面合法即可。

cdef extern from "a.h":
    int ADD(int a, int b)
    int SUB(int a, int b)
    int MUL(int a, int b)
def py_ADD(int a, int b):
   return ADD(a, b)
def py_SUB(int a, int b):
    return SUB(a, b)
def py_MUL(int a, int b):
    return MUL(a, b)

注意:SUB 函数和 MUL 函数对应的宏分别定义在 b.h 和 c.h 里面,但是我们只引入了 a.h,原因就是 a.h 里面已经包含了 b.h 和 c.h。

当然下面这种做法也是可以的:

cdef extern from "a.h":
    int ADD(int a, int b)
cdef extern from "b.h":
    int SUB(int a, int b)
cdef extern from "c.h":
    int MUL(int a, int b)
def py_ADD(int a, int b):
   return ADD(a, b)
def py_SUB(int a, int b):
    return SUB(a, b)
def py_MUL(int a, int b):
    return MUL(a, b)

a.h 里面包含了 b.h 和 c.h,所以上面相当于将 b.h 和 c.h 引入了两遍,但 C 允许将一个头文件 include 多次,所以没问题。

可毕竟 a.h 里面已经包含了所有的内容,我们直接在 cdef extern from "a.h" 里面把想要使用的 C 结构写上即可,没有必要再引入 b.h 和 c.h。如果真的不想写在 cdef extern from "a.h"里面的话,还可以这么做:

cdef extern from "a.h":
    int ADD(int a, int b)
cdef extern from *:
    int SUB(int a, int b)
cdef extern from *:
    int MUL(int a, int b)

因为 SUB 和 MUL 在引入 a.h 的时候就已经在里面了,只不过我们需要显式地在 extern 块里面声明之后才能使用它。而 cdef extern from * 则表示里面的 C 结构在其它使用 cdef extern from 导入的头文件中已经存在了,因此会去别的已导入的头文件中找,所以下面的做法也是可以的:

cdef extern from "a.h":
    pass
cdef extern from *:
    int ADD(int a, int b)
    int SUB(int a, int b)
    int MUL(int a, int b)

在 Cython 中导入了头文件,但是可以不使用里面的 C 结构,并且不用的话需要使用 pass 做一个占位符。而我们将使用的 C 结构写在了 cdef extern from * 下面,表示这些 C 结构在导入的头文件中已经存在了,而我们目前只导入了 a.h,那么 ADD、SUB、MUL 就都会去 a.h 当中找,所以此时也是可以的。

以上四种导入方式都是合法的,可以自己测试一下。

导入头文件的花样还是比较多的,但最好还是以直观、清晰为主,像最后两种导入方式就有点刻意了。


以注释的形式嵌入 C 代码




如果你用过 CGO 的话估计会深有体会, Go 支持以注释的形式嵌入 C 代码,而 Cython 同样是支持的,并且这些 C 代码要写在 extern 块中。当然我们说是注释其实不太准确,应该是三引号括起来的字符串,或者说 docstring 也可以。

// header.h
int add(int a, int b);

以上是一个简单的头文件,里面只有一个 add 函数的声明,但是没有具体实现,因为实现我们放在了 pyx 文件中。

cdef extern from "header.h":
    """
    int add(int a, int b) {
        return a + b;
    }
    """
    int add(int a, int b)
def my_add(int a, int b):
    return add(a, b)

然后我们来进行编译:

from distutils.core import setup, Extension
from Cython.Build import cythonize
ext = [Extension("cython_test",
                 sources=["cython_test.pyx"])]
setup(ext_modules=cythonize(ext, language_level=3))

最后导入一下进行测试:

import cython_test
print(cython_test.my_add(6, 5))  # 11

是不是很有趣呢?直接将 C 代码写在 docstring 里面,等同于写在源文件中。另外我们说 cdef extern from 除了可以导入头文件之外,还可以导入源文件,所以上面的代码还可以再改一下。当然,虽然 Cython 支持这么做,但还是不建议这么使用。

cdef extern from *:
    """
    int add(int a, int b) {
        return a + b;
    }
    """
    int add(int a, int b)
def my_add(int a, int b):
    return add(a, b)

此时没有涉及到任何的头文件、源文件,但确实是合法的 Cython 代码,因为我们将 C 代码写在了 docstring 中。不过显然这么做没什么意义,直接在里面通过 cdef 定义一个 C 级函数即可,没必要先用 C 定义、然后再使用 cdef extern from 引入,之所以这么做只是想表明 Cython 支持这种做法。

并且当涉及到 C 时,绝大部分都不是源文件的形式,而是动态库,至于如何引入动态库后面会说,总之通过 docstring 写入 C 代码这个功能了解一下即可。


小结




以上我们就了解了如何使用 Cython 包装外部的 C 代码,具体做法是通过 cdef extern from 引入头文件,在里面写上想要使用的 C 级结构。但头文件只存放声明,而负责具体实现的源文件,则在编译的时候通过 sources 参数指定。

并且 Cython 除了可以包装以源文件形式存在的 C 代码,还可以包装静态库和动态库。而本篇文章介绍的包装方式,需要 C 代码以源文件(或者说文本文件)的形式存在,至于如何包装静态库和动态库,我们后续再聊。


E N D


相关文章
|
5月前
|
开发者 Python
|
4月前
|
缓存 监控 程序员
Python中的装饰器是一种特殊类型的声明,它允许程序员在不修改原有函数或类代码的基础上,通过在函数定义前添加额外的逻辑来增强或修改其行为。
【6月更文挑战第30天】Python装饰器是无侵入性地增强函数行为的工具,它们是接收函数并返回新函数的可调用对象。通过`@decorator`语法,可以在不修改原函数代码的情况下,添加如日志、性能监控等功能。装饰器促进代码复用、模块化,并保持源代码整洁。例如,`timer_decorator`能测量函数运行时间,展示其灵活性。
38 0
|
2月前
|
数据安全/隐私保护 开发者 Python
在 Python 中定义封装?
【8月更文挑战第29天】
25 8
|
2月前
|
测试技术 数据处理 数据格式
Python中动态类和动态方法的创建与调用
【8月更文挑战第5天】在Python中,`type`函数可用于创建动态类,结合`types.MethodType`可创建动态方法。例如,定义`dynamic_method`后,可通过`type`创建包含该方法的`DynamicClass`。同样,对于已存在的类实例,可通过`types.MethodType`绑定新方法。这种动态特性适用于自动化测试框架或数据处理应用等场景,实现根据需求动态生成类及方法以执行特定逻辑。
|
4月前
|
存储 Python
在Python中,匿名函数(lambda表达式)是一种简洁的创建小型、一次性使用的函数的方式。
【6月更文挑战第24天】Python的匿名函数,即lambda表达式,用于创建一次性的小型函数,常作为高阶函数如`map()`, `filter()`, `reduce()`的参数。lambda表达式以`lambda`开头,后跟参数列表,冒号分隔参数和单行表达式体。例如,`lambda x, y: x + y`定义了一个求和函数。在调用时,它们与普通函数相同。例如,`map(lambda x: x ** 2, [1, 2, 3, 4, 5])`会返回一个列表,其中包含原列表元素的平方。
47 4
|
5月前
|
Python
Python模块的定义与应用
在Python编程中,模块是一个非常重要的概念。模块是包含Python定义和语句的文件,文件名通常以`.py`为后缀。模块将程序划分为不同的部分,使得代码更加清晰、易于维护,并且可以实现代码复用。本文将详细探讨Python模块的定义、创建、导入以及使用,帮助读者更好地理解和应用模块。
|
5月前
|
存储 Python
解释Python中的函数参数传递机制是什么样的?
解释Python中的函数参数传递机制是什么样的?
41 2
|
5月前
|
前端开发 程序员 开发者
自己封装的一些工具函数
自己封装的一些工具函数
深入理解 Python 中的函数参数传递机制
在 Python 中,对于函数的参数传递,有两种主要的方式:传值和传引用。事实上,Python 的参数传递是一种“传对象引用”的方式。接下来的文章我们将详细介绍 Python 的函数参数传递机制,这对理解 Python 编程语言的底层实现以及优化你的代码都非常有帮助。
|
API
这代码,你不包装下?
不管做什么事情,我们都要有一颗上进的心,写代码也是如此。最开始要写得出,然后要写得对,然后要写得又对又好,最后再追求那个传说中的快。
58 0