C/C++ & Python 融合之道

Python 解释器

4c932dd0-77c4-4ac0-b31e-787de00fc4dd

正如软件系统通常分为定义与实现两部分,编程语言也是。

  • 比如 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 为例:

  1. foo.py
  2. foo.pyc (如果有编译过的字节码文件存在)
  3. foo.so (在 Unix-like 系统)
  4. foo.pyd (在 Windows 系统,相当于 DLL)
  5. _foo.so (可能的 C 扩展模块名字)
  6. 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 库都在通过这种方式兼顾效率与性能,如:NumPyPandasTensorflowPyTorch 等等。

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 模块调用。