使用 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.hstruct
Girl1 {
char *name;
int age;
};typedef struct {
char *name;
int age;
}
Girl2;以上是一个 C 的头文件,我们在 Cython 中导入之后要怎么使用呢?
# cython_test.pyxcdef extern from "header.h":
# 定义结构体要和 C 保持一致
# C 使用 struct Girl1{...},那么这里也是如此
# 这里依旧省略了 cdef,写成 cdef struct Girl1 也可以
struct Girl1:
char *name
int age
# C 使用 typedef struct {...} Girl2
# 这里也要使用 ctypedef
ctypedefstruct Girl2:
char *name
int age# 对于结构体而言, 里面的成员只能用 C 中的类型
# 而创建结构体实例都是使用 "cdef 结构体类型 变量名 = " 的方式
cdefGirl1 g1 = Girl1("komeiji satori", 16)
cdefGirl2 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 freecdef externfrom"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 cythonizeext = [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
intfunc(int a, int b) {
return a + b;
}以上是一个 C 源文件,我们也是可以直接通过 cdef extern from 来引入的:
cdef externfrom "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 cythonizeext = [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 externfrom "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 externfrom "a.h":
int ADD(int a, int b)cdef externfrom "b.h":
int SUB(int a, int b)
cdef externfrom "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 externfrom "a.h":
int ADD(int a, int b)cdef externfrom *:
int SUB(int a, int b)
cdef externfrom *:
int MUL(int a, int b)
因为 SUB 和 MUL 在引入 a.h 的时候就已经在里面了,只不过我们需要显式地在 extern 块里面声明之后才能使用它。而 cdef extern from * 则表示里面的 C 结构在其它使用 cdef extern from 导入的头文件中已经存在了,因此会去别的已导入的头文件中找,所以下面的做法也是可以的:
cdef externfrom "a.h":
passcdef externfrom *:
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 externfrom"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 cythonizeext = [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 externfrom *:
"""
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