【Cython教程】通过Cython编写Python的C++拓展库
前言
官方的Python是由C语言编写,所以就像我之前发布的文章那样,Python可以调用动态链接库(在Windows中是dll格式)实现通过Python执行C代码逻辑。但dll的弊端在于,导出类型应该为c类型,但凡涉及字符串或者数组,你就要使用指针,而且要在写Python中写显示调用的代码,还是设置指针类型,这无疑增加了使用者的难度。另外,你使用C语言这样的中级语言编写代码,也会让你有巧妇难为无米之炊的感觉。
我们知道,pyd模块(Windows平台下是pyd,Linux平台是so)是python的扩展库模块文件,是可以通过其他语言编写的专给Python用的二进制文件。调用pyd相比调用dll可方便不少。
然而直接编写pyd模块,就需要了解Python的底层逻辑,了解PyObject也有一定的学习成本,而且编写时候也离不开指针,需要自己转换成PyObject类型以及异常处理。
不过,随着Cyton的引入大大方便了pyd的编写,你只管实现逻辑,至于怎么转换成Python类型交给Cython。不过个人认为Cython最牛逼的一点是对c++的支持。
官方文档地址(建议先看我的教程在看官方文档,不然容易看不懂):
在Cython中使用C++
在开始之前别忘了装Cython模块:
pip install cython
编写步骤
1.了解C++和Python之间的转换类型
pyd本质就是做一个或者多个导出函数或者类给Python调用(导出自定义c++类我们不讲,这个在官方文档有详细介绍,我只讲常用的易上手的和官方不怎么涉及的)那么,导出函数的参数类型,返回值类型就需要规范一下。这个具体的细节我们后续在说。先了解一下这个图。
2.编写C++代码:
我们需要编写实现逻辑的c++代码,我这里以一个字符串分割函数(split为例):
splitcp.h
#include <string>
#include <vector>
using namespace std;
vector<string> split(string s, string sep);//声明一个函数
不废话,直接使用这种vector<string>这种复杂类型。其他教程用double,int类型函数,那真的鸡肋。没有实际意义,看完都不知道这种vector<string>复杂类型怎么处理。
需要的头文件,命名空间都在头文件中定义,cpp文件只引用头文件。(个人习惯,不强制要求)
splitcp.cpp
#include "splitcp.h"
vector<string> split(string s, string sep) {
vector<string> result;
size_t start = 0;
size_t sep_len = sep.size();
size_t pos;
while (true) {
pos = s.find(sep, start);
if (pos == s.npos) {
result.push_back(s.substr(start));
break;
}
result.push_back(s.substr(start, pos - start));
start = pos + sep_len;
}
return result;
}
编写完代码后要进行测试,先保证能在c++环境中成功运行。
int main() {
string a = "你好,,世界,,this ,is,,c++";
string sp = ",,";
auto res = split(a, sp);
for (auto& i : res) {
cout << i << endl;
}
}
测试通过,说明咱们写的代码没毛病,那么接下来就可以编写Cython配置文件了。
3.编写Cython导入文件pxd
这一步最复杂也最容易出错,希望朋友们仔细阅读,因为Cython配置文件不像写代码,还有VS等IDE软件给你错误标红。这个写错了没什么提示的。所以要认真编写。
先打开VS Code 下载pyx插件
然后新建一个splitcp.pxd的文件。 这个文件名称是与上面的cpp文件名称一样的,我们要导出上面的那个cpp文件的函数,这个pxd文件名就跟他一样。、
splitcp.pxd:
# distutils: language = c++
from libcpp.vector cimport vector
from libcpp.string cimport string
cdef extern from "splitcp.h":
vector[string] split(string s, string sep);
cdef extern from "splitcp.cpp":
pass
第一行的注释别省略,这是告诉Cython使用c++语言。
第三行和第四行的代码,导入了c++标准库中的vector和string类型(注意是cimport不是import)。因为咱们要到出的函数split的参数,返回值用到了这两个类型,所以需要导入,不然Cython不认识这是啥类型。
这行代码就是从头文件中声明导出的函数:
可以发现区别是在头文件中vector是尖括号加string,而在pxd文件中尖括号换成了方括号,即vector[string],这是因为在Python中泛型就是用的[ ],你要是还是写<>,Python不认识的。
值得注意的是,在头文件中声明了命名空间std,所以vect前面不加std::,我强烈建议你这样做,不然,你写成std::vector<string>容易编译失败。
这行是导入cpp文件内容,pass就是表示文件内容全都要。、
我们在这里导入了一组的c++代码,如果你有多个cpp文件想导入,也可以写多个导入语句。
然后把cpp文件和头文件都与splitcp.pxd放在一起
4.编写Cython导出文件pyx
上面的步骤是将cpp代码导出交给Cython(即用pxd引入给Cython),接下来要编写pyx文件,告诉Cython如何导出split函数给Python调用。新建一个split.pyx文件(注意:pyx文件不能和cpp文件,pxd文件的名字相同,一定不能相同)。
split.pyx:
# distutils: language=c++
# cython: language_level=3
from splitcp cimport *
def split_str(s: str, sep: str) -> list:
return [x for x in split(s.encode(), sep.encode())]
第一行不用说了,第二行是指定python版本的3,因为python分py2和py3,语法不一样。咱们常用的都是py3。
第四行
引用导入文件,把split函数引进来,需要调用。
这个函数定义就是在调用cpp函数split 然后包装成Python的数据类型,转发出去,供Python调用,因为根据第一张图我们知道:
splith函数的参数是string类型,那么python这边的传入参数对应的是bytes类型,(注意:不是str
类型)所以当Python想传str字符串时,就需要将str参数通过encode方法转成bytes,传给cpp函数split:
然后,split函数的返回值类型vector,返回给python之后就是列表类型所以函数最后我们用列表推导式进行操作。但列表推导式中的x还是bytes类型,因为vector<string>中的string到python这边是bytes。因此整个split_str函数的返回值是list[bytes]类型。当然你也可以写成:
def split_str(s: str, sep: str) -> list:
return [x.decode() for x in split(s.encode(), sep.encode())]
这样返回值就是list[str]类型了。
5.编写执行编译的文件setup
这一步就简单了,新建一个setup.py文件,你只需要照抄下面的代码:
setup.py:
from setuptools import setup, Extension
from Cython.Build import cythonize
# 执行命令:python setup.py build_ext --inplace
# 定义扩展模块
ext_modules = [
Extension(
name="split", # 模块名称
sources=["split.pyx"], # 源文件
language="c++", # 使用 C++
)
]
# 调用 setup 函数
setup(
ext_modules=cythonize(ext_modules), # 编译扩展模块
zip_safe=False,
)
你只需要根据实际情况修改name模块名称以及source的pyx文件名即可。(强烈建议这两保持一样的名称)
6.编译pyd模块
这一步是最容易报错的一步。如果你上面3.4.5步有任何错误(尤其是第3步),都会体现在这编译步骤上,导致编译不通过。
打开终端,确保你的python已经添加到环境变量中,在命令行中python回车不报错。或者使用conda的使能环境,我这里使用的是minicoda:
然后,先确认一下,上面编写的文件都在相同目录中。
确认完后,切换工作目录到,文件所在的目录
敲dir再次确认目录正确。
之后执行:
python setup.py build_ext --inplace
之后,文件夹中就会多一个以pyd结尾的文件:
其中cp312,说明这个模块编译用的Python环境是3.12版本,也用于Python3.12。amd64是64位。
7.调用pyd模块
终于到达最后一步,如何调用生成的模块。新建一个test_pyd.py文件,测试pyd:
import split# pyd模块的名词,是pyd文件名.最前面的名称后面的cp312不用管
lsa =split.split_str("你好,,世界,,tHIS,is,a,,test.",",,")
for i in lsa:
print(i.decode('utf-8'))
调用pyd模块时候会发现,import split有波浪线,这是因为IDE(VS Code)并不能识别pyx文件,pyd文件为Python的拓展包文件,如果你有强迫症非要让ide正确识别,可以写一个split.pyi文件这个就是类似cpp头文件一样,用来声明函数定义的。直接把pyx中的调用函数头复制到pyi文件再加上…就行了(注意,pyi文件不要写函数具体定义内容)
这样IDE就能正确识别了。
拓展内容:
仅仅是以上内容还不足以说明本篇文章的含金量。下面要讲的是,如何将含有三方库的C++代码,编译成pyd模块。
c++在编译第三方库代码时,需要指定三方库的头文件目录和库目录(有的不需要库目录),一般来说,都是通过Vistual studio的项目设置指定的。那么同理,如果我们要需要用Cython编译含有三方库的话,也需要在编译时指定目录,然后,官方文档中似乎并没有关于如何调用c++三方库的方法,只给出了c库的方法:
我没有试过这个方法在c++中是否可用,但没关系,咱们不用这个。
我们知道,如果是c++标准库的内容是不用指定目录的,这是因为Cython在编译过程中将标准库目录传给了c++编译器。因此如果我们将三方库放到标准库目录下,不就能够正常编译了吗?
好主意!如何查看标准库在哪个目录呢?
打开Vistual studio 随便建一个c++空项目,查看项目属性
找到VC++,有个包含目录,库目录,这两个就是c++标准库目录的位置,点击编辑 查看计算的值。
绿框中的就是头文件目录,同理你也可以找到库目录,这里就不放图了。
值得注意的是库目录区分x64和x86.所以你的三方库lib文件要放在对于的目录下
比如说boost库lib文件:
x86就是x32。gd就是debug版本没gd是release版本。用到哪个库,你就放进去。不用的库就别拷进去了。
我们这里用Eigen库举例,这个库只需要头文件包含即可。
咱们正常用Eigen库时,是要包含头文件目录是…\eigen-3.3.8这一层,而在代码中包含时需要
#inclide <Eigen/…> 前面加了Eigen/ 这其实对应的是上图中的文件夹,因此如果你不想cpp包含头文件的方式,就把Eigen这个文件夹,拷贝到标准包含目录中。
如果你要把外层的eigen-3.3.8文件夹拷贝到标准包含目录中,那么你代码中就得写
#include<eigen-3.3.8/Eigen/…> 要理清这个目录层级关系。
三方库拷贝好后,接下来的步骤就和之前一样了,下面简略的说一下:
编写cpp代码:(注意:不要通过Vistual studio 的项目设置添加Eigen库,这样的话目录拷贝错了,你都无法察觉,报错就说明你目录找的不对,目录层级没整清楚)
// ConsoleApplication2.cpp : 此文件包含 "main" 函数。程序执行将在此处开始并结束。
//
#include <iostream>
#include <string>
#include <vector>
#include <Eigen/Dense>
using namespace Eigen;
using namespace std;
double abc(vector<int> a, vector<int> b) {
Vector3d v1(a[0], a[1], a[2]);
Vector3d v2(b[0], b[1], b[2]);
return v1.adjoint() * v2;
};
int main()
{
vector<int> v = {1,2,3};
vector<int> w{ 4, 5, 6 };
std::cout << abc(v,w);
}
测试通过后将h文件和cpp文件放到一个干净的目录中,在VS Code打开这个目录
enc.h
enc.cpp
编写pxd文件(注意文件名要和cpp一致):
# distutils: language = c++
from libcpp.vector cimport vector
cdef extern from "enc.h":
double abc(vector[int] a, vector[int] b)
cdef extern from "enc.cpp":
pass
编写pyx文件(注意文件名要和pxd文件不一致):
# distutils: language=c++
# cython: language_level=3
from enc cimport *
def call_abc(a:list, b:list):
if len(a)<3 or len(b)<3:
return 0.0
else:
return abc(a,b)
这里简单说一下这个函数返回的是double类型 不用管,cython自动转成python的float,int也一样,直接转成int。bool类型的话,我建议改成int。这块简单就不说了
setup.py
from setuptools import setup, Extension
from Cython.Build import cythonize
# python setup.py build_ext --inplace
# 定义扩展模块
ext_modules = [
Extension(
name="encp", # 模块名称
sources=["encp.pyx"], # 源文件
language="c++", # 使用 C++
)
]
# 调用 setup 函数
setup(
ext_modules=cythonize(ext_modules), # 编译扩展模块
zip_safe=False,
)
还是的,记得根据实际情况修改模块名称,文件名称。
编译:
调用:
import encp
res=encp.call_abc([1, 2, 3],[4, 5, 6])
print(res)
作者:卖女孩的小火柴คิดถึง