Python 解释器
正如软件系统通常分为定义与实现两部分,编程语言也是。
比如 C++ 标准是由国际标准化组织 ( ISO ) 下属的
ISO/IEC JTC1/SC22/WG21
特别工作组制定的,最新标准为 C++ 20 (ISO/IEC 14882:2020),这是定义;而其实现则有 GNU 的 gcc、微软的 visual c++、Apple 发起的 clang 等等,任何人都可以按照定义去实现自己的实现。Java 规范则是由 Oracle 主导的 JCP 所制定,有 Oracle Java SE、OpenJDK、Corretto、AdoptOpen 等大量不同的实现。
更别论应用更为广泛的 JavaScript 语言,ECMAScript 的实现者至少有两位数。
Python 作为一门广泛使用坐拥庞大社区支持的编程语言,更是如此。
Python 的语言规范是由 Python 软件基金会(PSF)负责制定的。
不同于 ISO(国际标准化组织)或 ECMA(欧洲计算机制造商协会)这些更为正式的标准化机构,Python 的规范制定过程相对非正式,主要由社区驱动。
与严格的书面标准相比,Python 标准给实现者留下了更多的灵活性和创造空间。
Python 编程语言的“实现” 通常被称为解释器,由 PSF 官方维护的基于 C 语言实现解释器 CPython 是使用最为广泛的解释器,也是大家默认 Python 实现。
除此之外,还有:
- 基于 JVM 实现的
Jython
graalpython
,方便与 Java 编程语言集成 - 基于 .NET 框架实现的
Python.NET
IronPython
- 基于 Python ( RPython,Python 语法子集)自举实现的 JIT 解释器
PyPy
- 适合于 IOT 场景的 MicroPython,裁减了部分标准库优化了其资源占用
- 哪里有 C 代码,哪里就有人在用 Rust 重写:RustPython
回到正题,由于我们默认的 Python 解释器 CPython 就是基于 C 语言实现的,所以其与 C/C++ 系语言天然就具有较高的亲和性,这正是我们能顺畅地在 C++ 中调用 Python 的理论基础所在。
在本文的后续部分,除非另有说明,提及的“解释器”均指的是由 Python 软件基金会官方维护的 CPython 解释器。
Python Extension Module
具体到 Python 是如何与 C++ 集成的,这就不得不提到 Extension Module 机制了。
众所周知,模块是 Python 代码组织中的一个重要单元,用于封装和重用代码,我们会通过 import
关键字引入一个模块从而可以调用外部的代码。
而 Python 的 Extension Module 机制则允许我们利用 C/C++ 去实现 Python module,C/C++ 中的函数会按照约定的签名映射成 Python 中的函数。
代码开发完成之后,我们需要将之编译成一个动态链接库,Python 解释器在 import
的时候会在PYTHONPATH
以及一些约定的目录下,按照约定的名称进行搜索,解释器导入模块的查找顺序如下,以 import foo
为例:
foo.py
- foo.pyc (如果有编译过的字节码文件存在)
foo.so
(在 Unix-like 系统)foo.pyd
(在 Windows 系统,相当于 DLL)_foo.so
(可能的 C 扩展模块名字)foo.cpython-36m-x86_64-linux-gnu.so
(可能的带有版本号和架构的 C 扩展文件名)
import
之后,在 Python 代码中,我们就可以像调用普通 Python 模块一样调用我们的 C/C++ 代码了。
举个完整的例子,假设我们想要实现一个加法模块,以下是相关操作步骤。
编辑 add_module.c
文件:
// 必须要 include python.h 头文件
#include <Python.h>
// C 函数实现加法
static PyObject* add(PyObject* self, PyObject* args) {
double a, b;
// 解析传入的Python参数为C类型变量,dd 表示两个 double 类型变量
if (!PyArg_ParseTuple(args, "dd", &a, &b)) {
return NULL;
}
// 返回计算结果,d 表示返回一个 double 类型的变量
return Py_BuildValue("d", a + b);
}
// 定义模块要导出的方法列表
static PyMethodDef AddMethods[] = {
{"add", add, METH_VARARGS, "Add two numbers"},
{NULL, NULL, 0, NULL} // Sentinel,标记数组的结束(C 数组无法直接获取长度,解释器会通过 Sentinel 来判断数组结尾)
};
// 定义模块
static struct PyModuleDef addmodule = {
PyModuleDef_HEAD_INIT,
"add_module", // 模块名
NULL, // 模块文档,可以设置为 NULL
-1, // 模块保留大小,-1 表示模块保持全局状态
AddMethods // 方法列表
};
// 初始化模块
PyMODINIT_FUNC PyInit_add_module(void) {
return PyModule_Create(&addmodule);
}
为了让这个 C 代码可以被 Python 解释器识别,我们需要将其编译成一个动态链接库:
gcc -Wall -shared -fPIC -I/usr/include/python3.9 -L/usr/lib/python3.9/config-x86_64-linux-gnu -lpython3.9 add_module.c -o add_module.so
此时我们就得到了一个 add_module.so
文件,直接在 Python 代码中 import
即可使用:
root@ubuntu:/ # python3.9
Python 3.9.18 (main, Feb 26 2024, 01:38:59)
[GCC 11.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import add_module
>>> print(add_module.add(6.6, 6.6))
13.2
>>>
在科学计算和人工智能领域,大量的 Python 库都在通过这种方式兼顾效率与性能,如:NumPy
、Pandas
、Tensorflow
、PyTorch
等等。
C++ -> Python
将 C++ 代码按照约定的方式编译即能让 Python 解释器识别,实现 Python 对于 C++ 代码的调用,那么反过来 C++ 该如何调用 Python 代码呢?
在 C/C++ 中调用 Python 解释器运行,这种机制被称为 Embedding Python,其实运作原理也是和 Extension Module 非常类似的。
Python 解释器提供了一套 API ( Python/C API ),供 C/C++ 代码调用,C/C++ 可以借由这套 API 实现对于解释器的高效集成与复杂交互操作,包括导入模块、创建和操作 Python 对象、函数调用、传输与转换数据等等,几乎所有 Python 代码能实现的操作,也能通过这套代码在 C/C++ 中实现。
举个例子,科学计算正是 Python 语言的强项之一,而 NumPy 是一个非常著名的 Python 科学计算库,我们可以尝试通过 Python/C API 将这种能力导入到 C/C++ 程序中,numpy.linalg.norm
函数可以计算一个一维数组对应的欧几里得范数,下文将介绍如何将该函数封装到 C/C++ 程序中;
由于在复杂场景下 C 代码可读性要弱于 C++ 代码,下文案例采用 C++ 实现:
#include <Python.h> // Python 头文件
#include <vector>
#include <iostream>
#include <stdexcept>
// C++ 包装函数
double numpy_linalg_norm(const std::vector<double> &in_array) {
Py_Initialize(); // 初始化 Python 解释器
PyObject *pName = PyUnicode_FromString("numpy.linalg"); // 加载 NumPy 模块
PyObject *pModule = PyImport_Import(pName);
Py_DECREF(pName); // 释放 pName(Python 引用计数 -1)
if (pModule == NULL) {
PyErr_Print();
Py_Finalize();
throw std::runtime_error("Failed to load numpy.linalg module");
}
PyObject *pFunc = PyObject_GetAttrString(pModule, "norm"); // 获取 norm 函数
if (!pFunc || !PyCallable_Check(pFunc)) {
PyErr_Print();
Py_Finalize();
throw std::runtime_error("Failed to retrieve norm function");
}
PyObject *pList = PyList_New(in_array.size()); // 创建一个 Python 列表
for (size_t i = 0; i < in_array.size(); ++i) { // 将 C++ 数组转换为 Python 列表
PyList_SetItem(pList, i, PyFloat_FromDouble(in_array[i]));
}
PyObject *pArgs = PyTuple_New(1); // 创建参数元组
PyTuple_SetItem(pArgs, 0, pList);
PyObject *pValue = PyObject_CallObject(pFunc, pArgs); // 调用函数
Py_DECREF(pArgs);
if (pValue == NULL) { // 判断是否成功调用
PyErr_Print();
Py_Finalize();
throw std::runtime_error("Failed to call norm function");
}
double result = PyFloat_AsDouble(pValue); // 处理返回值
Py_DECREF(pValue);
Py_DECREF(pFunc);
Py_DECREF(pModule);
Py_Finalize(); // 关闭 Python 解释器
return result;
}
int main() {
try {
std::vector<double> in_array = {1.0, 2.0, 3.0, 4.0, 5.0, 6.0};
double norm = numpy_linalg_norm(in_array);
std::cout << "L2 Norm of the array is: " << norm << std::endl;
} catch (const std::exception &e) {
std::cerr << "An error occurred: " << e.what() << std::endl;
return 1;
}
return 0;
}
这里由于依赖到了 numpy
库,所以我们需要事先通过 Python 的包管理程序安装它:
python3.9 -m pip install numpy
然后保存这段代码,编译运行就能在标准输出中看到这个数组对应的欧几里得范数了:
g++ -o cpp_call_python_demo main.cpp -I/usr/include/python3.9 -lpython3.9
Cython
尽管 Python/C API
为 C/C++ 调用 Python 提供了理论上的可行性,但是我们通过上方的案例也不难看出,其复杂程度是相当高的,即使 Python 中诸如赋值、初始化这样简单的操作都需要映射成 C/C++ 中的一个函数,另外还需要进行相当频繁地错误判断与处理,手动引用释放等逻辑,复杂程序不亚于在二十一世纪手写汇编程序。
为了实现 numpy.linalg.norm([1, 2, 3, 4, 5, 6])
这一行代码,我们总共写了整整五十行代码,如果再多几行 Python 代码想必 C/C++ 程序的复杂度更是指数级爆炸增长。
Obviously,我们还需要一层”抽象“,而 Cython 可能正是我们寻找的答案。
The most widely used Python to C compiler.
在 Cython 的 Github 主页,它是这么介绍自己的。
让我们来分析一下这句话,compiler
是宾语,意味着 Cython 本质上是一个编译器,而这个编译器的定语Python to C
则表明它这个编译器的输入是 Python 语言,而输出是 C 语言,这句话向我们精炼地概括了 Cython 的核心特性。
Cython 官网首页的这一段话,则是其更加详细地介绍:
The Cython language is a superset of the Python language that additionally supports calling C functions and declaring C types on variables and class attributes.
这句话中的宾语则是”编程语言“,可能会让人有点困惑,不过正如第一节中所描述的: ”编程语言也是由定义与实现两部分组成的“,Cython 也是。
Cython 本质上是一门编程语言,在 Python 语法的基础上,加入了对于 C 语法的支持,使得它能非常恰当的胜任两门语言中的那一层胶水,不管是通过 Cython 开发 Extension Module,或者是 Embedding 到 C/C++ 程序中,都能省事很多。
Cython 代码通常是以 .pyx
结尾,.pxd
文件则类比于 C 语言中的 .h
头文件,用于存放结构定义,前面提到 Cython 是一个编译器,所以这门编程语言是一门编译型语言,Cython 编译器会每一个源代码文件编译成一个 C 或者 C++ 文件,这取决于我们的配置;
然后我们需要再借助 C/C++ 编译器的帮助,将它们进一步编译成包含机器码指令的动态链接库,这就是我们最终的产物了,这个动态链接库是一个非常神奇的东西,胶水两边的语言都能无缝调用。
首先它是符合 Python Extension Module 规范的,所以能被纯 Python 代码 import
,其次它还能暴露 public
的 C/C++ 函数,所以也能在 C/C++ 程序中被调用,在两边代码的调用者视角中,就是简简单单引了个库,调个函数它就能把事给办了,不论在哪一边都是妥妥的一等公民。
作为一门编程语言其语法细节当然也不是简单几笔就能概括完的,这里就不展开了,我们还是通过一个程序案例的方式来直观地感受一下。
这里我们直接将上文基于 Python/C API 实现的 demo,转换成用 Cython 实现:
首先我们需要安装 Cython 编译器:
python3.9 -m pip install cython
编辑文件 numpy_wrapper.pyx
:
# distutils: language = c++
import numpy
from libcpp.vector cimport vector # Cython 内置了对于部分 C++ STL 结构的封装
# 申明为 public 表明该函数需要被导出
cdef public double numpy_linalg_norm(const vector[double] & in_array):
cdef tuple py_tuple = tuple() # 将 C++ vector 转换为 Python tuple
cdef int i
for i in range(in_array.size()):
py_tuple += (in_array[i],)
return numpy.linalg.norm(py_tuple) # Python 调用
这就是我们 cython
版本的代码了,可以看到其语法风格和 Python 基本差不多。
然后我们需要配置一下构建逻辑 setup.py
:
from Cython.Build import cythonize
from setuptools import setup
from setuptools.extension import Extension
extensions = [
Extension(
"numpy_wrapper",
["numpy_wrapper.pyx"],
language="c++", # 使用 C++ 语言特性编译成 C++ 源文件
extra_compile_args=["-std=c++20"], # 指示编译器使用 C++20 标准
extra_link_args=["-std=c++20"],
)
]
setup(
name='numpy_wrapper',
ext_modules=cythonize(
extensions,
language_level=3, # 指定 Cython 编译器使用 Python 3 语法
)
)
编译之后:python3.9 setup.py build_ext --inplace
,我们就能看到在当前文件夹下方多出来了几个文件:
root@ubuntu# ls -1
build
numpy_wrapper.cpp
numpy_wrapper.cpython-39-x86_64-linux-gnu.so
numpy_wrapper.h
numpy_wrapper.pyx
setup.py
.cpp
和 .h 是 Cython 编译的产物,这两个文件进一步调用 gcc 编译就生成了我们上文提到的动态链接库:numpy_wrapper.cpython-39-x86_64-linux-gnu.so
。
然后我们就能像 C++ 程序调用常规第三方库的方式去开发了:
- include 这个头文件
- 链接这个动态链接库,不过在这之前需要按照标准的动态链接库文件名格式重命名一下
- libpython 库也还是照样需要引用的
组合一下编译命令就长这样了:
g++ -o cpp_call_python_demo main.cpp -I/usr/include/python3.9 -lpython3.9 -L. -lnumpy_wrapper
然后再简单修改一下我们的 C++ 代码就能得到同样的输出:
#include <Python.h>
#include <vector>
#include <iostream>
#include <stdexcept>
#include "numpy_wrapper.h"
// 初始化 Python 解释器,导入模块
void initialize_py_interpreter() {
PyImport_AppendInittab("numpy_wrapper", PyInit_numpy_wrapper);
Py_Initialize();
PyImport_ImportModule("numpy_wrapper");
}
int main() {
initialize_py_interpreter();
try {
std::vector<double> in_array = {1.0, 2.0, 3.0, 4.0, 5.0, 6.0};
double norm = numpy_linalg_norm(in_array);
std::cout << "L2 Norm of the array is: " << norm << std::endl;
} catch (const std::exception &e) {
std::cerr << "An error occurred: " << e.what() << std::endl;
return 1;
}
return 0;
}
而当我们反过来需要在 Python 中调用 C++ 的时候,Cython 也可以帮我们免去按照约定的 Python Extension Module 开发的中间层,C++ 代码还是按照正常流程开发就行,Cython 中可以直接 include 对应的 C++ 头文件,链接对应的动态链接库,然后再暴露给纯 Python 模块调用。