目录
Cython、Rust 等:如何选择 Python 扩展的语言
有时纯 Python 代码不够用,你需要用编译语言(如 C、C++ 或 Rust)来实现扩展。可能是因为你的代码运行太慢,需要加速,或者你需要访问用其他语言编写的库。
根据你的具体情况和需求,你可能需要选择不同的工具。但该选择哪个呢?
让我们看看有哪些选项,然后通过不同的场景来分析哪种工具最适合。
C、C++、Cython 和 Rust:快速概述
这四种语言都可以编译为机器码,并且可能比 Python 快得多。除此之外,它们在许多方面有显著差异。
C
C 语言最初创建于 1970 年,虽然自那时以来已经进行了大量更新,但它本质上仍然是同一种语言。C 语言提供的功能相对简单,只提供函数,没有类等高级特性。
默认的 Python 解释器是用 C 实现的,因此通常被称为 CPython。因此,实现 Python 扩展的默认 API 是 C,使用提供的 C API。此外,像 Linux 这样的 Unix 系统是用 C 实现的,因此操作系统和库的 API 通常也是以 C API 的形式提供的。
CPython 的 C API 相当冗长,有很多样板代码。以下是一个简单的 C API 包装器示例(未测试),取自 Python 文档:
// Licensed under PSF License Agreement
#include <stdlib.h>
#include "Python.h"
static PyObject *
spam_system(PyObject *self, PyObject *args)
{
const char *command;
int sts;
if (!PyArg_ParseTuple(args, "s", &command))
return NULL;
sts = system(command);
return PyLong_FromLong(sts);
}
static PyMethodDef SpamMethods[] = {
{"system", spam_system, METH_VARARGS,
"Execute a shell command."},
{NULL, NULL, 0, NULL} /* Sentinel */
};
static struct PyModuleDef spammodule = {
PyModuleDef_HEAD_INIT,
"spam",
NULL,
-1,
SpamMethods
};
PyMODINIT_FUNC
PyInit_spam(void)
{
return PyModule_Create(&spammodule);
}
所有这些代码只是为了实现一个简单的函数!虽然其中一些代码是共享的,但本质上它非常冗长。其他问题将在后面讨论,因为它们与 C++ 和 Cython 共享。
C++
C++ 最初是“带类的 C”,但后来发展成为一种非常不同、非常表达力强、同时也非常复杂的编程语言。它与 C 代码向后兼容,除了一些边缘情况。
C++ 的表达力更强,因此可以创建更简洁的 Python 扩展 API。pybind11
库 就是一个很好的例子,展示了 C++ 在编写 Python 扩展时的简洁性。
以下是一个简单的 C++ 示例(未测试),相当于前面的 C 示例。它要短得多!
#include <stdlib.h>
#include <pybind11/pybind11.h>
PYBIND11_MODULE(spam, m) {
m.def("system", &system, "Wrap the system() C API");
}
Cython
鉴于 Python C API 的冗长,另一个选择是 Cython。Cython 是一种混合语言:它实现了 Python 语法,但可以插入被翻译为 C 或 C++ 的代码。
前面的 C 示例在 Cython 中可能如下所示(未测试,但接近实际情况):
cdef extern from "<stdlib.h>" nogil:
int system (const char *command)
def my_system(command):
return system(command)
在许多情况下,Python 和 C/C++ 类型之间的转换可以自动完成,因此不需要手动调用 PyLong_FromLong()
等函数。
Cython 可以调用 C 和 C++ 代码,甚至可以子类化 C++ 类。不过,由于 C++ 语言的复杂性,Cython 对 C++ 的支持有些有限。
C、C++ 和 Cython 的共享问题
内存不安全
C、C++ 和 Cython 都需要手动管理内存:内存必须手动分配和释放,不像 Python 那样使用自动垃圾回收。部分由于内存管理的实现方式,它们都存在内存不安全的问题:很容易意外覆盖错误的内存、从未初始化的内存中读取数据等。这可能导致崩溃、静默数据损坏和安全漏洞;在 C 或 C++ 编写的软件中,高达 70% 的安全漏洞 是由于内存不安全引起的。
缺乏构建系统和包管理
C 和 C++ 也缺乏标准的构建系统,也没有标准的依赖安装方式。在 Python 中,你可以通过 pip install
从源代码或包仓库安装包;C 语言没有类似的工具。
Python 提供了一种跨平台构建 C Python 扩展的方式。但如果你依赖于现有的第三方库,尤其是在跨平台打包时,你会遇到很多麻烦。
并发性
在这些语言中编写快速且不会崩溃、数据损坏或出现其他问题的并发代码可能非常困难。
通常更优的替代方案:Rust
Rust 是一种新语言,它从一开始就被设计为 C++ 的替代品,尽管它也可以用来替代 C。Rust 默认情况下不会出现内存不安全问题,并且允许“无畏并发”:你可以编写并发代码而不必担心数据竞争或内存损坏。同时,Rust 可以与 C 或 C++ 一样快,并且它提供了逃生舱口,允许执行 C 和 C++ 允许的不安全操作。
Rust 还有一个现代化的构建和包管理系统,称为 cargo
,以及一个现代化的包分发系统(crates.io
)。这意味着添加新的 Rust 依赖项非常简单,而不是像大多数 C 库那样使用定制的、复杂的或拼凑的构建系统。
简而言之,Rust 在安全性和工具链方面从根本上优于 C 和 C++,同时仍然提供相同的性能和功能。
Rust 的主要缺点是,为了实现安全性,它必须使用与 C 或 C++ 不同的编程模型,这有一定的学习曲线。此外,作为一种相对较新的语言,它的库和学习资源较少。
Rust 可以调用 C 代码,或者在一定程度上调用 C++ 代码,但如果这是你的主要用例,你可能更适合使用其他解决方案。
对于 Python 集成,你可以使用 PyO3 来编写 Python 扩展,然后使用 Maturin 进行简单的 Rust 扩展打包,或者使用 setuptools-rust
如果你需要与现有的 Python 包集成。
选择正确的工具
现在你已经了解了这些选项,接下来我们通过几个场景来分析哪种工具最适合。
场景 #1:加速数学计算
假设你有一个 Python 函数在进行一些数学计算,你需要让它运行得更快。在这种情况下,Cython 是最简单的加速代码的选项。
由于 Cython 支持与 Python 相同的语法,你可以直接使用相同的 Python 代码,只需在一些变量上添加 C 类型,它可能会运行得更快:
def fib(int n):
cdef int a, b
a, b = 0, 1
while b < n:
a, b = b, a + b
return n
其他替代方案
场景 #2:实现一个众所周知的数据结构、算法或 API 客户端
在这种情况下,首先要做的是寻找现有的实现。例如,一个客户要求我加速一些代码,这些代码会检查一组字符串中是否有任何一个出现在另一个字符串中。解决这个问题的常见算法是 Aho-Corasick,所以我寻找现有的实现。
结果发现,已经有一个用 Cython 编写的实现,所以我本可以直接推荐它。然而,考虑到他们对性能的需求,我进一步测试,发现 Rust 的 ahocorasick
库更快,因此我将其包装为 Python。有了现有的库,包装代码相当简短;结果是比 Cython 库快得多。额外的性能并不是因为实现语言,而是因为更复杂的算法实现。
如果有一个现有的 Rust 实现,并且它看起来健壮且维护良好,考虑包装它。
如果你只能找到 C 或 C++ 的现有库,或者找不到任何现有的库,我们将进入下一个场景。
场景 #3:包装一个 C 或 C++ 库
如果你要包装一个 C 或 C++ 库:
- 对于 C,最简单的选择是 Cython。你可以直接使用 Python C API,但这涉及到很多样板代码。
- 对于 C++,你可以使用 Cython,但 Cython 对 C++ 的支持有限,你需要使用 Cython 的语法重新实现所有头文件。因此,我建议使用
pybind11
,或者如果你使用的编译器支持 C++17,可以使用更快的nanobind
库。
其他减少样板代码的替代方案
场景 #4:从头开始编写大量代码
有时你无法使用现有的库,你只能自己编写代码。而且你希望它使用一种快速的语言以提高性能,否则你可能会直接用 Python 编写。
在这种情况下,你可以使用 C 或 C++,但这样你可能会增加更多不安全且容易出错的代码。编写正确的 C 或 C++ 代码太难了。
Cython 是另一个选择,但最终你还是会回到 C/C++,老实说,我不想用 Cython 编写大量代码。由于它编译为 C/C++,你有两个编译阶段,而且 Cython 的错误可能很难调试。此外,你用 Cython 编写的任何代码都与 Python 紧密绑定,无法在其他上下文中重用。
因此,如果你确实需要编写大量代码,我强烈建议使用 Rust。虽然有一定的学习曲线,但最终的安全性、表达力和性能是非常值得的。