C/C++脚本化: 使用Python作为现有项目的扩展

C/C++脚本化: 使用Python作为现有项目的扩展

本文来源:Zero’s Donkey Den
原文地址:7-0.cc/cpp_scripting_exploration_2
转载许可:未经授权,禁止转载

在之前的探索篇中我们已经讨论了脚本化的意义、方式以及脚本语言的选择。本篇我们主要讨论:如何优雅地通过Python来扩展C++,结合脚本语言的优势,提高现有项目的适应性及开发效率,从而达到降本增效的目的。[1]

请添加图片描述

1 C++与Python

这里引用一段《Building Hybrid Systems with Boost.Python》中的一段描述:

Python and C++ are in many ways as different as two languages could be: while C++ is usually compiled to machine-code, Python is interpreted. Python’s dynamic type system is often cited as the foundation of its flexibility, while in C++ static typing is the cornerstone of its efficiency. C++ has an intricate and difficult compile-time meta-language, while in Python, practically everything happens at runtime.

Yet for many programmers, these very differences mean that Python and C++ complement one another perfectly. Performance bottlenecks in Python programs can be rewritten in C++ for maximal speed, and authors of powerful C++ libraries choose Python as a middleware language for its flexible system integration capabilities.

Python和C++存在很多差异: C++被编译成机器码,而Python是解释执行的。Python的动态类型系统常常被认为是它灵活性的基础,而在C++的静态类型是C++效率的基石。C++有一种复杂晦涩的编译时元语言,而在Python中,几乎一切都发生在运行时。

然而对很多程序员来说, 这些差异意味着Python和C++可以完美互补。Python程序的性能瓶颈可以用C++重写, 强大的C++库的作者选择Python作为中间件语言, 以实现其灵活的系统集成能力。

译文来自:使用Boost.Python构建混合系统(译)

简单总结一下就是:“表达同一个意思,中文要比英文短” (★ᴗ★)

Python具有简洁、灵活、高效的语法,丰富多样的生态,C++与其结合可以完美的互补双方的缺点、发挥自身优势,可谓是天作之合。

而在C++准标准库Boost中,Boost.Python的出现为这种结合提供了一个强力粘合剂。

2 Boost.Python

Boost.Python是一个用于连接Python和C++的框架,它允许开发人员快速无缝地将c++类、函数和对象暴露给Python,反之亦然,其特点如下[2]

  • 无缝集成
    Boost.Python提供了一个简单而灵活的接口,使得在C++和Python之间进行函数调用、数据交换以及对象创建非常方便。可以轻松地将现有的C++代码包装成Python模块,无需对原有代码做太多修改(当然也可以快速的将Python嵌入到C++中)。
  • 完整的类型支持
    Boost.Python支持将几乎所有的C++类型(包括基本类型、自定义类、指针、引用等)导出到Python中,并能够自动处理类型转换。这使得在C++和Python之间传递数据变得简单,无需手动编写繁琐的转换代码。
  • 支持C++到Python的异常转换
    当Python调用C++时,产生的C++异常可以自动转换到Python,也可以添加异常转换处理器,实现自定义的异常转换规则。
  • 支持C++中操作Python对象
    Boost.Python提供一系列的Python对象封装,允许开发者可以方便的操作诸如:字典、列表、元组、字符串、数值等内建对象。
  • 支持C++虚拟函数, 并能在Python中重写
    这意味着我们可以暴露C++抽象类,并能在Python中继承、重写,消除语言接口的差异。
  • 支持C++迭代器导出为Python迭代器

2.1 开发环境需求

通常Boost.Python提供了一个编译好的二进制版本,以libboost_python38…命名,其中的数字38表示版本号。如果Python版本或者二进制版本与我们期望的不符合,则需要编译自己的二进制版本。

下面简述如何在Windows平台编译Boost[.Python]组件 [3]

  1. 我们假设Boost源码目录位于boost_1_69_0

  2. 编辑配置文件user-config.jam修改Python依赖 [4]

    • 编辑如下文件
      boost_1_69_0\tools\build\src\user-config.jam

    • 若不存在则从如下位置位置拷贝
      boost_1_69_0\tools\build\example\user-config.jam

    • 修改如下内容(文件路径改成自己的)

      # ---------------------
      # Python configuration.
      # --------------------- 
      # Configure specific Python version.
      # using python : 3.1 : /usr/bin/python3 : /usr/include/python3.1 :  usr/lib ; 
      
      using python : 3.8 : H:/local/Python-3.8.3 : H:/local/Python-3.8.3/include : H:/local/Python-3.8.3 ;
      
  3. 编译Boost.Python(需要在Visual Studio命令提示中执行) [5]

$cd boost_1_69_0 # 1. 进入boost目录
$bootstrap       # 2. 生成构建工具:b2.exe,bjam.exe

# 3. 编译静态运行时版本
$b2 stage --toolset=msvc-14.1 --with-python runtime-link=static threading=multi debug release

# 4. 编译动态运行时版本
$b2 stage --toolset=msvc-14.1 --with-python runtime-link=shared threading=multi debug release 

3 Python embeddable package

在Windows平台下,Python官方主要提供了两种二进制包:[6]

前者是完整的安装程序,除了提供了Python解释器、标准库、工具和各种第三方库的集合外,还会配置环境变量和注册表项,方便系统范围内的Python使用,这也是通常情况下安装及使用的包。

而后者通常用于将Python作为嵌入式脚本语言集成到其他软件中,或者用于构建独立的可执行文件。它的特点是文件体积较小,只包含了核心的Python组件(通常是python310.zip*pyd),没有额外的工具和库。你可以将Python的解释器和相关文件复制到你的应用程序目录中,并从你的应用程序中调用Python解释器来执行Python代码。

在这里我们重点关注Windows embeddable package发行包,通常包文件结构如下:

.
|-- python.exe
|-- pythonw.exe
|-- python3.dll (64.2KB)
|-- python310.dll (3.95MB)
|-- python310._pth
|-- python310.zip
|-- python.cat
|-- LICENSE.txt
|-- *.pyd
`-- *.dll
  • python.exe、pythonw.exe
    Python解释器可执行文件,两者的区别是后者无命令行界面,通常用于在后台运行Python脚本。

  • python310.zip
    Python标准库:仅包含预编译并优化后的.pyc文件。

  • python310.dll、python3.dll
    Python解释器的动态链接库,提供Python解释器的核心功能,两者的区别是:后者是前者的壳(备用名),即把所有接口都转发到前者。

  • *.pyd、*.dll [7]
    两者都是Windows的DLL文件,区别是前者属于Python的扩展模块,必须存在PyInit_ModelName()接口,可以通过Python代码“import ModelName”来使用这个模块,而后者只是一个依赖的动态连接库。

  • *._pth
    该文件用于覆盖sys.path的内容,主要影响Python查找模块的搜索路径,如果Python初始化时检测到._pth文件,将发生以下行为:

    • 忽略所有注册的环境变量, 启动隔离模式(isolated mode).
    • 文件中的所有路径添加到sys.path, 路径可以是绝对也可以是相对路径.
    • 忽略空行以及#开头的行.
    • site不会导入, 除非文件指定了import site.

4 通过Boost.Python嵌入解释器扩展C++

在前面探索篇中我们已经简单的展示过:如何通过Boost.Process来与Python交互,这里我们重点关注嵌入方式的扩展。[8]

再次声明嵌入解释器不适合并发场景,有并发场景的考虑子进程方式。[9]

通常嵌入解释器我们需要了解如下几个情景:

  • 接口如何绑定(暴露)到另一个语言?
  • 数据、事件如何传递?
  • 错误如何处理?

带着上面的问题,我们先来看一个简单的例子:

#include <iostream>
#include <boost/python.hpp>

namespace python = boost::python;

// 定义全局函数
std::string hello() {
    return "Hello from C++!";
}

// 定义抽象类: Base
class Base : public boost::noncopyable
{
public:
    virtual ~Base() {};
    virtual std::string hello() = 0;
};

// 通过Boost.Python包装Base类
struct BaseWrap : Base, python::wrapper<Base>
{
    virtual std::string hello() {
        return this->get_override("hello")(); // get_override() 用于获取重写的函数指针
    }
};

// 定义Python模块: embedded_hello
BOOST_PYTHON_MODULE(embedded_hello)
{
    python::def("hello", hello); // 绑定全局函数: hello

    // 绑定抽象类
    python::class_<BaseWrap, boost::noncopyable> base("Base");
}

int main(int argc, char** argv)
{
    // 将上面定义的模块注册到解释器中, 作为"内建模块"
    if (PyImport_AppendInittab("embedded_hello", PyInit_embedded_hello) == -1)
        return -1;

    // 初始化解释器
    Py_Initialize();

    // 检索主模块以及主模块的名称空间(它将作为我们运行Python代码的上下文环境)
    python::object main = python::import("__main__");
    python::object global(main.attr("__dict__"));

    // 在指定的上下文(global)中, 导入模块并调用其接口
    // 输出: Hello from C++!
    python::exec(
        "from embedded_hello import * \n"
        "print(hello())", global, global);

    // 定义一个派生类, 继承自C++导入的Base类
    python::exec(
        "class PythonDerived(Base):          \n"
        "    def hello(self):                \n"
        "        return 'Hello from Python!' \n",
        global, global);

    // 检索定义的派生类
    python::object PythonDerived = global["PythonDerived"];

    // 实例化Python类对象, 并转型为基类的引用
    python::object py_base = PythonDerived();
    Base& py = python::extract<Base&>(py_base) BOOST_EXTRACT_WORKAROUND;

    // 这里实际上调用的是Python中重写的方法
    assert(py.hello() == "Hello from Python!");

    // 设置数据到Python
    global["lanuage"] = python::str("C++");

    // 输出: The value of this variable comes from C++!
    python::exec(
        "print(f'The value of this variable comes from {lanuage}!') \n"
        "message = 'This string comes from Python!'",
        global, global);

    // 从Python上下文中提取message变量的值
    std::string message = python::extract<std::string>(global["message"]);
    assert(message == "This string comes from Python!");

    return 0;
}

4.1 接口绑定

当我们需要在一个语言中调用另一个语言提供的模块或方法时(比如Python中调用C++),就不得不提到接口绑定了。简单说就是:先将A语言的接口暴露到B语言中,这样B语言才能调用A语言提供的接口。

在上面的例子中,C++通过Boost.Python提供的机制,我们可以很方便的将C++接口绑定到Python环境中:

// 定义全局函数
std::string hello() {
  return "Hello from C++!";
}

// 定义Python模块: embedded_hello
BOOST_PYTHON_MODULE(embedded_hello) {
  python::def("hello", hello); // 绑定全局函数: hello
}

// 省略无关代码...

// 注册到解释器作为内建模块
PyImport_AppendInittab("embedded_hello", PyInit_embedded_hello)

// 导入模块并调用
python::exec(
  "from embedded_hello import * \n"
  "print(hello())", global, global);

而反过来,由于嵌入的关系,在C++中可以直接调用Python方法与模块,也可以直接操作Python对象(接口即函数也是一种对象):

// 定义Python函数
python::exec(
  "def getAddress(name):\n"
  "   if name == 'jack':\n"
  "      return '6 East Changan Avenue PeKing'\n"
  "   return 'NO.70 dong feng dong Rd.Guangzhou'",
  global, global);

// 从上下文中检索函数对象并调用
auto getAddress = global["getAddress"];
auto address0 = python::extract<std::string>(getAddress("jack"))();
auto address1 = python::extract<std::string>(getAddress("null"))();

assert(address0 == "6 East Changan Avenue PeKing");
assert(address1 == "NO.70 dong feng dong Rd.Guangzhou");

4.2 数据与事件的传递

跨语言的交互中,我们经常会考虑如何将数据(数据结构)传递到另一边。再之后就是当某个事件发生后,如何让另一边恰到好处地得到机会,执行事件处理逻辑。

数据的传输可以通过如下方式:

  • 环境上下文对象
    通过环境上下文对象可以直接访问或修改脚本运行环境中的内容,相当于访问脚本中的全局变量一样:
    • global["lanuage"] = python::str("C++");
    • std::string message = python::extract<std::string>(global["message"]);
  • 调用函数的参数及返回值
    直接调用另一个语言绑定(暴露)的接口,将必要的数据通过参数与返回值传递。
  • 命令行参数(sys.args)
    在执行Python脚本文件的时候,可能需要指定一些命令行参数,用以设置执行需要的选项。

而事件通常通过接口调用来传递(包含回调)。

4.3 错误处理(Boost.Python异常处理机制)

程序运行过程中错误可能会发生在任何地方,我们来设想一下如下的情景,当错误发生时如何妥善的处理。

4.3.1 Python调用C++过程中的错误

C++中主要通过错误码和异常来表示错误,其中的异常会被Boost.Python框架的handle_exception_impl(f)捕获,内部通过CPython的PyErr_*()系列方法 [10],将标准C++异常转换为Python异常。

而自定义异常与错误码则需要用户注册异常处理器或手动通过PyErr_*()系列方法转换(参考:register_exception_translator)。

4.3.2 C++调用Python过程中的错误

Python中错误也是以异常的方式沿着调用栈向上传递,如果没有被处理的话会一直到达CPython的PyRun_*()系列方法,最终被Boost.Python检测后并转换为C++异常 error_already_set 并继续向上传递。

因此我们需要拦截error_already_set异常,并处理Python错误。

try {
    // 执行可能引发Python错误的代码
    python::object result = python::exec("invalid_code()");

    // 在这里处理成功情况
    // ...

} catch (const python::error_already_set& e) {
    // 捕获并处理Python错误
    // ...

    PyErr_Print(); // 这里简单的打印异常信息到解释器的错误输出中
}

需要注意的是 error_already_set 对象并不包含Python异常的信息,仅表示发生了异常,即PyErr_Occurred() 的返回值为true。如要获取关联的Python异常信息,则还需要通过PyErr_Fetch()等接口提取异常对象以及栈跟踪对象。

更多错误处理细节参考:Pyembed

4.4 如何在C++调试器中查看Python对象的内容

在嵌入Python解释器与C++结合开发过程中,会经常需要在C++中操作Python对象,可能是PyObject*对象,也可能是boost::python::object包装类。

在调试过程中我们很难直观的在调试器中观察这些对象的值,这使得调试此类代码时显得比较麻烦,不过好在Visual Studio *系列调试器提供的Natvis框架,这使我们可以自定义调试器对象查看的解析逻辑。[11]

知乎上有大佬基于Python 3.5制作了一版.natvis [12],我在此基础上兼容了Python 3.8的版本的,放在项目根目录或natvis文件搜索目录即可启用。

Python-Debuger-Support.png

文件地址: Pyembed.cpython38.natvis

5 Pyembed

现在我们知道,通过Boost.Python可以在很大程度上简化嵌入解释器的工作量,但是仍然存在很多情况使我们不得不使用CPython的API接口。

因此博主在Boost.Python的基础上封装了一个简单的C++开源库,主要目的在于解决如下问题:

  • 如何重定向解释器的标准输入、输出?
  • 如何在C++中处理Python异常,而不是打印到标准输出?
  • 如何处理非ASCII字符的文件名(比如:在Windows下含有中文的文件名)?
  • 如何在执行Python文件时,指定命令行参数?
  • 如何在Visual Studio调试器中查看Python对象(PyObject*)的值?

项目地址:https://github.com/ZeroKwok/pyembed

6 扩展阅读

6.1 如何扩展 Windows embeddable package

我们知道embeddable package是Python的最小环境,其中仅包含了最基本的标准库。

通常我们为其安装第三方模块来支撑项目的开发,而Python中管理第三方包最好的办法就是使用pip包管理器,这里我们简单介绍如何安装pip、第三方包以及扩展pythonXY.zip的方法。

  • 下载: python-3.8.10-embed-win32.zip
  • 解压: python-3.8.10-embed-win32
  • 安装 pip
    • $ cd python-3.8.10-embed-win32
    • $ wget https://bootstrap.pypa.io/get-pip.py
    • $ ./python.exe get-pip.py
  • python38._pth添加如下配置:
    Lib\site-packages
  • 安装第三方包
    • $ ./python.exe -m pip install flask
    • $ ./python.exe -m pip install -t Packages flask
    • $ ./python.exe -m pip install --only-binary=:all: -t Packages flask
  • 测试完毕后,将新增模块的*.pyc添加到python38.zip中(这通常是将Python环境打包跟随应用程序一起发行阶段)。

6.2 *.pyc & *.pyo

Python中*.pyc文件是用来缓存无关于平台的字节码文件,主要为了提升.py源代码文件的加载速度。

.pyc(Python Compiled)由解释器或者py_compile()将源代码编译成无关于平台的字节码,下图是该文件在加载流程中的细节。

https://peps.python.org/_images/pep-3147-1.png

如果解释器开启了优化设置,那么模块被导入的时候,解释器也会产生.pyo文件。即.pyo.pyc相同, 而区别在于前者是经过优化过后更加紧凑的字节码文件.

python3.5以前是.pyo,python3.5以后就是产生.opt-1.pyc文件

参考资料:

7. 引用资料

[1] C/C++脚本化: 探索篇
[2] Boost.Python: Synopsis
[3] Boost: Build Binaries From Source
[4] Boost.Python: Configuring Boost.Build: Python Configuration Parameters
[5] Boost.Python: b2 - Boost.Build documentation
[6] Python: Using Python on Windows - Different installers available for Windows
[7] Python: *.pyd 文件和 DLL 文件相同吗?
[8] Python: Embedding Python in Another Application
[9] Boost.Python: Is Boost.Python thread-aware/compatible with multiple interpreters?
[10] Python: Extending Python with C or C++: Intermezzo: Errors and Exceptions
[11] Microsoft: Create custom views of C++ objects in the debugger using the Natvis framework
[12] 知乎.斩月: python_d.natvis文件

  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值