【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)

作者:卖女孩的小火柴คิดถึง

物联沃分享整理
物联沃-IOTWORD物联网 » 【Cython教程】通过Cython编写Python的C++拓展库

发表回复