Cython 与 C/C++ 的交互

用 Cython 也用了很有一段时间了,这次就介绍一下它的最重要功能 —— 使用 Cython 来封装 C/C++ 代码。最基本的封装方法可以参见 Cython 文档中的相关页面:Interfacing with External C CodeUsing C++ in Cython,本文介绍主要是比较重要和常用的 Cython/C++ 交互特性,而自定义 Python 拓展类(而不是封装现有 C++)的一些操作可以参考官方教程

封装 C++ 代码时,最重要的关键词就是 extern,在定义函数时使用这个关键字就说明该声明是外部的,而使用 cdef extern from 语句就能指定声明对应的头文件。例如如果要封装函数 func,对应的 Cython 语句是

1
2
cdef extern from "func.c":
void func(int arg)

文件结构

首先讲一下 Cython 的文件结构。如果你之有一个小模块需要封装的话你可以把所有代码写到同一个 pyx 里进行编译,否则的话你就可以利用 Cython 的目录结构来管理多个层次的代码。Cython 的文件一共有三种:pyxpxi注意与 pyi 区分)和 pxd注意与 pyd 区分)。

.pyx 是 Cython 的源文件,类似于.cpp 文件在 C++ 中的地位,而对应.h 头文件地位的则是 pyi。在 Cython 中添加 import 'header.pyi' 的语句就会将 header.pyi 文件中的内容原封不动地直接插入当前位置,这与 C++ 的#include 语句的作用是相同的。而 pxd 则是另一套符号化的逻辑,.pxd 文件中只能声明函数、声明类型、不能有函数和类型的定义内容(除了 inline 函数外),而在 cimportpxd 的定义之后当前代码便引入了对应的函数或者类型签名。这个工作方式则更符合 C++ 中头文件的实际用途。定义了 pxd 后就可以在多个 Cython 文件之间共享同一个类型了。

不过既然涉及了 include 语法,就必然要指定类似于 C++ 的引用路径了。pxipxd 文件的引用路径可以在 cythonize 过程中手动指定,而 pxd 由于是符号化的还可以通过新建__init__.pxd 的方式来实现类似于 Python 的引用方法。只要在 Cython 搜索目录下的文件夹中包含__init__.pxd 文件,Cython 就会认为这是一个 Cython 库,之后就可以用 cimport 语句通过与 Python 中 import 相类似的语法将对应模块文件(.pxd 文件)引用进来。当然,pxd 文件也可以通过命令参数直接导入。关于如何组织这些文件以及头文件之间的关系,读者可以参考我写的 PCL 封装库Cython 的相关文档

函数在 pxd 中的定义不能显式指定默认参数,而是必须用 * 代替,例如 cdef void func(a=0)pxd 中声明的话需要改为 cdef void func(a=*)

类型封装

Cython 对 C++ 的类型提供了基本可用的封装语法。为什么说基本可用,是因为 Cython 目前对模板的支持还非常有限,因此实际上可以说 Cython 只支持到 C++98 的程度。不过尽管如此,Cython 已经能够完成大多数代码的封装需求了。Cython 对 class 的支持通过 cdef cppclass <class-name> 来实现,这里 cppclass 关键词是为了和 Cython 的 class 关键词进行区分。Cython 中 class 关键词代表的是和 Python 一致的 PyObject 对象,代表的是 Python 类型,而 cppclass 则指代 C++ 原生类型,由于 Cython 文件中无法直接编写 C++ 代码,因此 cdef cppclass 语句通常在 cdef extern from 的语法块中,用来封装现有的 C++ 类型。另外一点需要注意的地方是 Cython 提供封装 enumstruct 的语法,但是针对的是 C 中的 enumstruct,而非 C++ 中的 enum classstruct(C++ 中 structclass 几乎没有区别)。如果要封装 C++ 版本的 enumstruct 可以直接使用 cppclass 关键词。以下是封装 C++ 类型的一个例子:

1
2
3
cdef extern from "test.h":
cdef cppclass Test:
void print()

别名与 namespace 关键字

由于 Cython 最后生成的是全局的 C 代码,因此在引用 C++ 类时需要明确声明类型含命名空间的全称,这里就需要用到别名的机制。Cython 允许从.h 文件中导入声明的时候给类型和方法改名字,具体用法如下

1
2
3
4
5
cdef extern from "<header-name>":
cdef void <new-function-name> "<origin-function-name>":
pass
cdef cppclass <new-class-name> "<origin-class-name>":
pass

简而言之就是在方法或者类型名称后添加引号,引号里写上原本 C++ 中的名字。这个机制有很多 tricky 的用法,它可以用来声明带命名空间的方法和类型、可以用来重命名 C++ 中的运算符、可以用来直接声明实例化的模板类型、甚至可以用来把 C++ 常量声明成类型用于模板参数(这种操作可以参考 eigency 库中的代码)。

其中针对第一种用法,为了简化带有命名空间对象的声明,Cython 加入了 namespace 关键字。在 cdef 语句中添加 namespace 从句可以使得 Cython 编译器默认给其包含的语句块中所有的类型加上对应的命名空间,例如

1
2
3
cdef extern from "test.h" namespace "ns":
cdef cppclass Test:
pass

与以下代码是等价的

1
2
3
cdef extern from "test.h":
cdef cppclass Test "ns::Test":
pass

模板支持

这个特性在之前介绍 Cython 类型的文章中也有提到过,这里补充一下它的一些特性。Cython 对 C++ 模板的支持通过 [] 符号实现,以下是 Cython 中对 vector 的封装代码可供参考

1
2
3
4
5
cdef extern from "<vector>" namespace "std" nogil:
cdef cppclass vector[T,ALLOCATOR=*]:
ctypedef T value_type
ctypedef ALLOCATOR allocator_type
...

其中 vector[T,ALLOCATOR=*] 对应的就是 C++ 中的 vector<T, ALLOCATOR> 符号。模板参数在 Cython 中同样可以有复数个,也可以有默认值,似乎现在也支持常数作为模板参数,不过我没有尝试过,而据说老版本是不支持常数模板参数的。

之前有提到 Cython 中对模板的支持是阉割过的,主要特征有以下几点:

  • Cython 不支持模板参数的类型声明访问。例如上面的 vector 类型声明中不能使用 ctypedef allocator_type.size_type size_type 这样的语法,而这样的类型推断在 C++ 中是有很多的。
  • Cython 不支持模板构造函数中包含新的模板参数
    不过 Cython 一直在改进对模板的支持,因此以后也很有可能会得到改进。

Buffer 协议

Cython 还针对性地支持了 Python 的 Buffer 协议,用来传递一块结构化的内存,这个协议的标准被记录在了提案 PEP-3118 中。这个协议通过__getbuffer____releasebuffer__两个 Cython 自定义的特殊函数实现,通过这个方式 Cython 代码就可以将 C++ 内存转化为 Python 识别的内存。因为 Numpy 支持将支持 Buffer 协议的对象转换为 ndarray,因此这个 Buffer 协议的通常用法是将一个 C++ 对象变成 Numpy 的矩阵。具体的使用案例也可以参照我的 pcl 封装库中的对应代码


本文介绍了Cython中操作C/C++对象的方法,不过仅仅介绍了一些进阶用法。如果是新手的话还是先参照之前提到两篇文档学习基本的函数、类型封装方法吧~