Python & C++ - pybind11 实现解析

2023-10-16 15:31:09 浏览数 (1)

0. 导语

IEG 自研引擎 CE 最早支持的脚本是 Lua, 在性能方面, Lua是有一定优势的. 但除此之外的工程组织, 以及现在即将面临的 AI 时代的语料问题, Lua 都很难很好的解决. 在这种情况下, 支持工程组织和语料更丰富的 Python, 就成了优先级较高的任务了. 由于Python的虚拟机以及相关的C API较复杂, 我们选择的方式是将 pybind11 - 一个Python社区知名度比较高, 实现质量也比较高的 Python 导出库与我们引擎的 C 反射适配的整合方式, 这样可以在工作量较小的情况下, 支持好 Python 脚本, 同时也能比较好的利用上引擎的C 反射实现. 在做好整合工作前, 我们肯定需要先较深入的了解 pybind11 的相关实现机制, 这也是本篇主要讲述的内容.


1. 为什么 pybind11 这类中间件是必要的

我们以 UE 官方的 PythonScriptPlugin 中的代码为例, 如果直接依赖 Python C API, 你实现出来的代码可能是如下这样的:

代码语言:javascript复制
// NOTE: _T = typing.TypeVar('_T') and Any/Type/Union/Mapping/Optional are defines by the Python typing module.
    static PyMethodDef PyMethods[] = {
        { PyGenUtil::PostInitFuncName, PyCFunctionCast(&FMethods::PostInit), METH_NOARGS, "_post_init(self) -> None -- called during Unreal object initialization (equivalent to PostInitProperties in C  )" },
        { "cast", PyCFunctionCast(&FMethods::Cast), METH_VARARGS | METH_CLASS, "cast(cls: Type[_T], object: object) -> _T -- cast the given object to this Unreal object type or raise an exception if the cast is not possible" },
        { "get_default_object", PyCFunctionCast(&FMethods::GetDefaultObject), METH_NOARGS | METH_CLASS, "get_default_object(cls: Type[_T]) -> _T -- get the Unreal class default object (CDO) of this type" },
        { "static_class", PyCFunctionCast(&FMethods::StaticClass), METH_NOARGS | METH_CLASS, "static_class(cls) -> Class -- get the Unreal class of this type" },
        { "get_class", PyCFunctionCast(&FMethods::GetClass), METH_NOARGS, "get_class(self) -> Class -- get the Unreal class of this instance" },
        { "get_outer", PyCFunctionCast(&FMethods::GetOuter), METH_NOARGS, "get_outer(self) -> Any -- get the outer object from this instance (if any)" },
        { "get_typed_outer", PyCFunctionCast(&FMethods::GetTypedOuter), METH_VARARGS, "get_typed_outer(self, type: Union[Class, type]) -> Any -- get the first outer object of the given type from this instance (if any)" },
        { "get_outermost", PyCFunctionCast(&FMethods::GetOutermost), METH_NOARGS, "get_outermost(self) -> Package -- get the outermost object (the package) from this instance" },
        { "is_package_external", PyCFunctionCast(&FMethods::IsPackageExternal), METH_NOARGS, "is_package_external(self) -> bool -- returns true if this instance has a different package than its outer's package" },
        { "get_package", PyCFunctionCast(&FMethods::GetPackage), METH_NOARGS, "get_package(self) -> Package -- get the package directly associated with this instance" },
        { "get_name", PyCFunctionCast(&FMethods::GetName), METH_NOARGS, "get_name(self) -> str -- get the name of this instance" },
        { "get_fname", PyCFunctionCast(&FMethods::GetFName), METH_NOARGS, "get_fname(self) -> Name -- get the name of this instance" },
        { "get_full_name", PyCFunctionCast(&FMethods::GetFullName), METH_NOARGS, "get_full_name(self) -> str -- get the full name (class name   full path) of this instance" },
        { "get_path_name", PyCFunctionCast(&FMethods::GetPathName), METH_NOARGS, "get_path_name(self) -> str -- get the path name of this instance" },
        { "get_world", PyCFunctionCast(&FMethods::GetWorld), METH_NOARGS, "get_world(self) -> Optional[World] -- get the world associated with this instance (if any)" },
        { "modify", PyCFunctionCast(&FMethods::Modify), METH_VARARGS, "modify(self, always_mark_dirty: bool = True) -> bool -- inform that this instance is about to be modified (tracks changes for undo/redo if transactional)" },
        { "rename", PyCFunctionCast(&FMethods::Rename), METH_VARARGS | METH_KEYWORDS, "rename(self, name: Union[Name, str]="None", outer: Optional[Object]=None) -> bool -- rename this instance and/or change its outer" },
        { "get_editor_property", PyCFunctionCast(&FMethods::GetEditorProperty), METH_VARARGS | METH_KEYWORDS, "get_editor_property(self, name: str) -> object -- get the value of any property visible to the editor" },
        { "set_editor_property", PyCFunctionCast(&FMethods::SetEditorProperty), METH_VARARGS | METH_KEYWORDS, "set_editor_property(self, name: str, value: object, notify_mode: PropertyAccessChangeNotifyMode=PropertyAccessChangeNotifyMode.DEFAULT) -> None -- set the value of any property visible to the editor, ensuring that the pre/post change notifications are called" },
        { "set_editor_properties", PyCFunctionCast(&FMethods::SetEditorProperties), METH_VARARGS, "set_editor_properties(self, properties: Mapping[str, object]) -> None -- set the value of any properties visible to the editor (from a name->value dict), ensuring that the pre/post change notifications are called" },
        { "call_method", PyCFunctionCast(&FMethods::CallMethod), METH_VARARGS | METH_KEYWORDS, "call_method(self, name: str, *args: Any, **kwargs: Mapping[str, object]) -> Any -- call a method on this object via Unreal reflection using the given ordered (tuple) or named (dict) argument data - allows calling methods that don't have Python glue" },
        { nullptr, nullptr, 0, nullptr }
    };

    PyTypeObject PyType = {
        PyVarObject_HEAD_INIT(nullptr, 0)
        "_ObjectBase", /* tp_name */
        sizeof(FPyWrapperObject), /* tp_basicsize */
    };

    PyType.tp_base = &PyWrapperBaseType;
    PyType.tp_new = (newfunc)&FFuncs::New;
    PyType.tp_dealloc = (destructor)&FFuncs::Dealloc;
    PyType.tp_init = (initproc)&FFuncs::Init;
    PyType.tp_str = (reprfunc)&FFuncs::Str;
    PyType.tp_repr = (reprfunc)&FFuncs::Str;
    PyType.tp_hash = (hashfunc)&FFuncs::Hash;

    PyType.tp_methods = PyMethods;

    PyType.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE;
    PyType.tp_doc = "Type for all Unreal exposed object instances";

我们需要非常了解 Python C API, 并且这类代码的污染也比较严重, 为了导出相关功能函数, 你可能需要写非常多的辅助代码. 而这些往往都是编译期已经可以获取的内容了, 而且编译期特性的使用也不会导致性能的下降.

这种情况下, 像 pybind11, boost.python 等中间件应运而生, 而 pybind11 对比实现复杂度和依赖都非常重的 boost.python, 显然更有优势, 功能实现和特性上 pybind11 也更占优, 落差从 GitHub上两个库的热度就能看出来了:

====2016年 pybind11 cppconn 演讲时的数据====

====到2023年4月, 本文写作的时间, 差距更大了====

下面让我们先从一个 pybind11 的示例开始, 逐步了解 pybind11 的设计实现.


1.1 pybind11 的简单使用

我们先通过一些测试代码来近距离的接触 pybind11, 这里我们以一个 3D 中常用的向量 Vector3 为例, Vector3 的声明如下:

代码语言:javascript复制
namespace gbf {
namespace math {

class Vector3 {
 public:
  double x;
  double y;
  double z;

  Vector3() : x(0.0), y(0.0), z(0.0) {}
  Vector3(double _x, double _y, double _z) : x(_x), y(_y), z(_z) {}

  ~Vector3() {}

  // Returns the length (magnitude) of the vector.
  double Length() const;

  /// Extract the primary (dominant) axis from this direction vector
  const Vector3& PrimaryAxis() const;
};

}  // namespace math
}  // namespace gbf

我们利用 pybind11 可以很方便的将 Vector3 导出到 python 指定的模块 math3d 中:

代码语言:javascript复制
// example 模块的初始化函数
PyObject *PyInit_math3d() {
  static pybind11::module_ math3d("math3d", "pybind11 example plugin");

  pybind11::class_<gbf::math::Vector3>(math3d, "Vector3")
      .def(pybind11::init<>())
      .def(pybind11::init<double, double, double>())
      .def("Length", &gbf::math::Vector3::Length)
      .def("PrimaryAxis", &gbf::math::Vector3::PrimaryAxis)
      .def_readwrite("x", &gbf::math::Vector3::x)
      .def_readwrite("y", &gbf::math::Vector3::y)
      .def_readwrite("z", &gbf::math::Vector3::z)
    ;
  return math3d.ptr();
}

这里我们直接尝试向 Python 注册了一个 math3d 模块, 并在这个模块中导出了一个 Vector3 的类(三维矢量的简单实现), 并导出了Vector3的属性和一些成员方法.

如果正确构建了测试环境, 以下代码:

代码语言:javascript复制
from time import time,ctime
import math3d

a = math3d.Vector3(3, 4, 5)
print('vec:', a.x, a.y, a.z)
print('primary axis:', a.PrimaryAxis())
print(f"vec len: {a.Length()}")

我们就能如上所示在 Python 中正确的访问到 math3d 模块下的 Vector3 类了.

借助 pybind11 和 Python C API, 我们可以方便的在 C 中创建 Python 脚本环境, 这里给出运行环境创建的一种方式:

代码语言:javascript复制
wchar_t libraryPath[] = L"../../../data/python/Lib";
  Py_SetPath(libraryPath);

  // 将 math3d 模块的初始化函数添加到内置模块表
  PyImport_AppendInittab("math3d", &PyInit_math3d);
/ 初始化 Python 解释器
  pybind11::scoped_interpreter guard{};

  PyRun_SimpleString(
 R"(# 此处插入测试用Python脚本)"
  );

此处是直接使用内置模块的方式注册的math3d, 很多时候我们是通过预处理的dll来提供模块给 Python 用的, 引擎很多内置类型我们可以更直接的通过上面这种方式, 在代码中直接注册内置模块, 导出相关功能给 Python脚本使用.


1.2 本节小结

本节中我们通过一个简单的示例了解了 pybind11 的基本使用方法, 从示例中我们也能看到, pybind11 提供了一种简洁的语法来定义模块和在模块中注册类和函数。模块本身是导出的起点, C 的类和函数的都依赖于某个模块导出到 Python 中, 如上例中的 math3d 模块.

那么 pybind11 是如何实现 C <-> Python 交互的呢, 后面的章节中我们将逐步介绍实现相关机制的基础设施, 逐步分析 pybind11 的核心实现机制.


2. pybind11 对 Python 对象的支持

Python 本身有丰富的类型系统, pybind11 也在 C 中对 Python 的对象体系进行了相关的抽象, 方便在 C 中直接操作 Python 虚拟机的上对象.


2.1 对象体系概述

Python 中的内置类型比较丰富, 所以要完成对整个 Python 对象体系在 C 中的还原, 也是一件较复杂的事情, pybind11 是层次化的完成这个目标的, 整体的 pybind11 对象图如下所示:

我们也使用层次化的方式对这些实现做进一步的说明:


2.1.1 pyobject_tag

该类的存在目的是为了在 C 更方便的区分 pybind11 实现的 Python 对象类型和非 Python 对象类型, 利用模板: 位于 pytypes.h 中:

代码语言:javascript复制
template <typename T>
using is_pyobject = std::is_base_of<pyobject_tag, remove_reference_t<T>>;

我们编译期就能快速的对图上定义的诸多类型进行判定, 从而对诸多 Python 对象类型使用正确的方式进行处理.


2.1.2 detail::object_api<Derived>

作用如类名, 提供对 Python 对象的统一 API外观, 部分接口定义如下: 位于 pytypes.h 中:

代码语言:javascript复制
/** rst
    A mixin class which adds common functions to `handle`, `object` and various accessors.
    The only requirement for `Derived` is to implement ``PyObject *Derived::ptr() const``.
endrst */
template <typename Derived>
class object_api : public pyobject_tag {
    const Derived &derived() const { return static_cast<const Derived &>(*this); }

public:
    iterator begin() const;
    iterator end() const;

    item_accessor operator[](handle key) const;
    item_accessor operator[](object &&key) const;
    item_accessor operator[](const char *key) const;

    obj_attr_accessor attr(handle key) const;
    obj_attr_accessor attr(object &&key) const;
    str_attr_accessor attr(const char *key) const;

    /// Check if the given item is contained within this object, i.e. ``item in obj``.
    template <typename T>
    bool contains(T &&item) const;

    template <return_value_policy policy = return_value_policy::automatic_reference,
              typename... Args>
    object operator()(Args &&...args) const;

    /// Equivalent to ``obj is other`` in Python.
    bool is(object_api const &other) const { return derived().ptr() == other.derived().ptr(); }
    /// Equivalent to ``obj is None`` in Python.
    bool is_none() const { return derived().ptr() == Py_None; }
    /// Equivalent to obj == other in Python

    object operator-() const;
    object operator~() const;
    object operator (object_api const &other) const;
    object operator =(object_api const &other);
    object operator-(object_api const &other) const;
    object operator-=(object_api const &other);
    object operator*(object_api const &other) const;
    //... 

    PYBIND11_DEPRECATED("Use py::str(obj) instead")
    pybind11::str str() const;

    /// Get or set the object's docstring, i.e. ``obj.__doc__``.
    str_attr_accessor doc() const;

    /// Return the object's current reference count
    int ref_count() const { return static_cast<int>(Py_REFCNT(derived().ptr())); }

    handle get_type() const;
};

我们依赖这种设施可以在 C 中方便的访问 Python 对象, 如直接利用operator() 来完成对对象__call__方法的调用, attr()查询对应 Python 对象的属性, str() 获取字符描述等.

[!tip] 另外我们注意到该类有一个Derived模板参数, 这实际上是一种 C 中称作: CRTP - curiously recurring template pattern 的特殊定制方法, 通过该方法, 我们可以以纯静态的方式在父类中对子类进行访问, 高性能的完成部分依赖虚表和继承才能完成的特性.


2.1.3 handle

Python 本身的 GC 实现比较特殊, 区别于大多语言使用的方式, 除了依赖 GC 对生命周期进行管理外, 也依赖引用计数的方式对对象的生命周期进行管理, 所以在 pybind11 中也不可避免的需要对 Python 对象的引用计数进行管理 , 这部分功能主要是由 pybind11::handle 来完成的. 其中提供了: - inc_ref(), dec_ref()方法 -> 用来控制当前 Python 对象的引用计数 - ptr()方法 ->方便我们直接获取原生的PyObject对象. 在处理函数的 C 参数传入传出处理的时候, pybind11 很多情况下是直接使用 handle 来完成相关功能的.


2.1.4 object

大部分 Python 对象的 C 抽象都使用它来作为基类, 继承自 handle 的 object, 除了提供了 handle 相关的能力外, 也额外扩展了cast() 方法, 以及后面介绍的依赖其实现的 reinterpret_borrow, reinterpret_steal方法, 图中左侧大多是 Python 专有的类型, 右侧则大多是能够简单转换为 C 类型的Python类型, 以及额外的用于对C 指针进行管理的 capsule 类型, 这些都继承自 object, 每个从 object 继承的类都有贴合自身实现的类型检查机制, 这样保证我们不容易使用错误的类型对 Python 中的对象进行操作, 具体每个类型的作用这里不一一展开描述了, 下面再具体介绍一下 pybind11 中控制 Python 对象生命周期的辅助设施.


2.1.5 detail::generic_typeclass_

generic_type 继承的 class_ 用于表达 C 类的各种信息, 上面示例中也可以看到: 位于 pybind11.h 中:

代码语言:javascript复制
pybind11::class_<gbf::math::Vector3>(example, "Vector3")
  .def(pybind11::init<>())
  .def(pybind11::init<double, double, double>())
  .def("Length", &gbf::math::Vector3::Length)
  .def("PrimaryAxis", &gbf::math::Vector3::PrimaryAxis)
  .def_readwrite("x", &gbf::math::Vector3::x)
  .def_readwrite("y", &gbf::math::Vector3::y)
  .def_readwrite("z", &gbf::math::Vector3::z)
;

我们通过 class_ 来注册 C 类, 它的构造函数, 成员函数, 成员变量等到 Python 中, class_ 最后会在 Python 中创建一个 PyTypeObject, 并关联 C 类处理需要的各种函数, 如创建对象中调用的init_instance, 析构时调用的 dealloc 等, 通过 class_ 以及内部关联的 PyTypeObject 和其上的各种定制函数, C 类和对象也就能被 Python 识别和使用了, 具体的细节我们在第3章中详细展开.


2.1.6 capsule

Python 中有一个对自定义指针进行管理的类型, 允许我们传入一个 const void* 的指针, 以及一个对该指针数据进行的析构函数, 构造一个 PyCapsule 类型的对象, 在 PyCapsule 需要被 GC 时, 就会自动调用我们传入的析构函数对相关数据进行析构. class_ 实现部分并未采用这种方式, 但这种轻量易用的封装方式在很多场合都能够很好的工作, 比如 tensor 中的代码: 位于 torch.h 中:

代码语言:javascript复制
src = Helper::alloc(std::move(*src));
parent_object = capsule(src, [](void *ptr) { Helper::free(reinterpret_cast<Type *>(ptr)); });

这种类型对于一些需要关联到 Python 中的自定义数据来说, 是非常友好的. 我们在阅读 pybind11 源码时也会发现 capsule 的使用.


2.2 生命周期控制的辅助设施

reinterpret_steal<>reinterpret_borrow<> 是 Pybind11 中的两个辅助函数,用于方便我们直接在 C 中用非 Python C API 的相对高级的方式直接操作 Python 对象, 其中 reinterpret_steal<> 会改变持有的 Python 对象的引用计数, 而 reinterpret_borrow<> 则不会.

[!note] 注意 pybind11 的 borrow 对引用计数的处理是通过object创建时引用计数 1, 销毁时引用计数-1, 来达成的不改变原始引用计数, 而不是我们想象中的不变, 所以我们应该尽量结合栈对象使用 reinterpret_steal<>reinterpret_borrow<>, 避免预期外的引用计数改变带来的 Side Effect.


2.2.1 reinterpret_borrow<>

reinterpret_borrow<> 用于从原始 PyObject* 类型创建一个 pybind11::object,而不影响引用计数(注意这里是通过构建时引用计数 1, 析构时引用计数 -1 来达成的)。这个函数常用于将已经持有引用计数的原始 Python 对象转换为 Pybind11 的 object 类型, 方便我们使用 pybind11 提供的一系列简单易用的接口。例如下面示例: cpp PyObject* raw_obj = ...; // 已经持有引用计数的 Python 对象 py::object obj = py::reinterpret_borrow<py::object>(raw_obj);

这里,raw_obj 是一个原始 Python 对象。通过使用 reinterpret_borrow<>,我们可以将其转换为 pybind11 的 object 类型, obj 脱离作用域后, 原始的 raw_obj 的引用计数会被还原到一开始的状态, 从而实现了一个对 Python 对象的间接式的 borrow


2.2.2 reinterpret_steal<>

reinterpret_steal<> 用于从原始 PyObject* 类型创建一个 pybind11::object,同时会接管对应 PyObject 的引用计数管理, 在对应的 pybind11::object 释放时, 相关的 PyObject 引用计数会 -1. 这个是与 borrow 有差异的地方. reinterpret_steal<> 执行的情况下, 对应的的 pybind11::object 对象脱离作用域后, Python 对象的引用计数实际上被执行了 -1 的操作.


2.2.3 Python对象支持小结

pybind11 通过提供一系列更简单易用的 Python 内置类型抽象, 在其中提供更简单易用的接口, 以及添加基础的引用计数自动处理的机制, 再结合像 reinterpret_borrow<> 以及 rinterpret_steal<> 之类的辅助设施, 让我们可以在不使用 Python C API的情况下, 也能简单高效的完成对 Python 各种基础对象的操作, 同时也为更进一步的 C 类导出提供了良好的底层支撑.


3. pybind11 对 C 类的支持

前面我们介绍了 pybind11 对 Python 对象的支持, 有了这部分能力, 我们就能基于它更容易的实现 pybind11 的核心功能 -- 将 C 类导出至 Python 使用. pybind11 支持 C 类导出到 Python 的机制我们可以通过下图简单概括:

要完成对 C 类的导出功能, pybind11 主要实现了两部分的核心功能: 1. Register过程: 利用C 的编译期特性, 我们在类型注册的时候, 完成 C 类型到 Python 类型的转换, 并且可以在Python中按名称索引对应的成员函数和属性. 这部分实现直接利用了前面一章中介绍的 pybind11::class_, 相关实现会在注册的过程中对所有的 C 函数和属性的 get/set 方法将完成类型擦除, 相关信息会被统一转移到类型 pybind11::cpp_function 中. 2. Runtime相关: Runtime的时候, 我们会需要在 Python对象 <-> C 对象中实现互转, 具体这部分功能由图中的两个类来完成, 在 pybind11 中, 所有的 C 类对象会被类型擦除到 pybind11::detail::instance 上, 我们需要注意 pybind11 这里的处理比较特殊, instance 负责对象的存储, 而图上的pybind11::detail::value_and_holder 负责完成 instance 对象的使用, 这种设计是因为对应的 instance 对象, 可能存在基类, 而很多时候我们将某个 C 对象当成它的基类来处理, 显然也是合法的, pybind11 需要自己处理这部分 C 反射相关的特性, 所以此处的设计稍显复杂, 本文我们主要关注流程和实现, 像这些非常细节的部分我们不会过多的展开. 我们接下来将分成两个小节分别展开 Register 部分和 Runtime相关的部分.


3.1 Register - C 类注册部分

我们先来了解 Register (注册)相关的核心类 class_: 位于 pybind11.h 中:

代码语言:javascript复制
template <typename type_, typename... options>
class class_ {
 public:
  template <typename... Extra>
  class_(handle scope, const char *name, const Extra &...extra) {
        using namespace detail;

        type_record record;
        record.scope = scope;
        record.name = name;
        record.type = &typeid(type);
        record.type_size = sizeof(conditional_t<has_alias, type_alias, type>);
        record.type_align = alignof(conditional_t<has_alias, type_alias, type> &);
        record.holder_size = sizeof(holder_type);
        record.init_instance = init_instance;
        record.dealloc = dealloc;
        record.default_holder = detail::is_instantiation<std::unique_ptr, holder_type>::value;

        set_operator_new<type>(&record);

        /* Register base classes specified via template arguments to class_, if any */
        PYBIND11_EXPAND_SIDE_EFFECTS(add_base<options>(record));

        /* Process optional arguments, if any */
        process_attributes<Extra...>::init(extra..., &record);

        generic_type::initialize(record);

        if (has_alias) {
            auto &instances = record.module_local ? get_local_internals().registered_types_cpp
                                                  : get_internals().registered_types_cpp;
            instances[std::type_index(typeid(type_alias))]
                = instances[std::type_index(typeid(type))];
        }

  }
};

pybind11 通过 pybind11::class_<type_, ...options>(scope, name, ...) 模板类的构造函数完成对一个 C 类型的注册, 其中类的模板参数: type_ -> 指定要导出的主类型, 如前例中的 Vector3. options -> 为主类型指定一系列的别名.

其中构造函数的参数: scope -> 父级模块对应的句柄, 如前例中的 math3d 模块. name -> 对应 C 类导出到模块中的名称, 如前例中的名称 "Vector3". ...extra -> 额外的配置参数

其中主要完成的操作包括: 1. type_record 信息填充, 其中包括scope, name, type - c 类型(目前使用 typeid() 实现), 以及几个操作函数的指针, init_instance, dealloc, operator_new. type_record 实际就是 pybind11 中用于记录的 C 类型相关的元信息的对象了. 2. Extra信息的处理(暂不展开) 3. 利用刚刚填充好的type_record调用generic_type::initialize(), 这个函数执行的操作主要是以下这些: a. make_new_python_type() 为C 类注册新的 Python 类型 b. 生成 C 类型对应的detail::type_info, 并存入registered_types_cppregistered_types_py 中, 两者分别对应 keystd::type_index 以及 PyTypeObject 的情况, 我们可以在运行时根据不同类型的 key 查询存储好的 detail::type_info. c. tb_base 的处理 d. 对 local 成员进行额外的处理, 以使外部成员可以正确的访问它. 4. 注册额外的alias type信息(暂不展开)

[!info] C 类型的查询是通过 RTTI 来实现的, 通过对具体类型调用 typeid(CxxClassType) 得到一个 std::type_info 类型的对象, 再通过这个对象构造支持哈希和比较的std::type_index, 我们就能将对应类型间接转换出支持查询的 std::type_index 了, 在没有完整实现 c 反射的地方, 这是一种很稳妥的对 c 类型进行查询处理的方式.

与 Python 虚拟机交互的注册主要发生在3a, 3b, 3c 中, 我们具体来看一下相关的实现:


3.1.1 make_new_python_type() - 3a 部分实现

位于 class.h 中:

代码语言:javascript复制
auto *metaclass
        = rec.metaclass.ptr() ? (PyTypeObject *) rec.metaclass.ptr() : internals.default_metaclass;

    auto *heap_type = (PyHeapTypeObject *) metaclass->tp_alloc(metaclass, 0);
    heap_type->ht_name = name.release().ptr();
#ifdef PYBIND11_BUILTIN_QUALNAME
    heap_type->ht_qualname = qualname.inc_ref().ptr();
#endif
    auto *type = &heap_type->ht_type;

整体流程比较简单, 利用先前创建好的 metaclass - 一般是预创建的 pybind11-type 类型, 创建对应的 PyHeapTypeObject, 并从中获取真正我们需要用到的PyTypeObject类型, 进行对PyTypeObject对象信息的填充. 最后我们当然还需要将生成的类型向对应的模块注册:

代码语言:javascript复制
setattr(rec.scope, rec.name, (PyObject *) type);

3.1.2 detail::type_info的生成和注册 - 3b 部分实现

位于 pybind11.h - generic_type::initialize() 中:

代码语言:javascript复制
auto *tinfo = new detail::type_info();
        tinfo->type = (PyTypeObject *) m_ptr;
        tinfo->cpptype = rec.type;
        //...

        auto &internals = get_internals();
        auto tindex = std::type_index(*rec.type);
        internals.registered_types_cpp[tindex] = tinfo;
        internals.registered_types_py[(PyTypeObject *) m_ptr] = {tinfo};

这部分的实现也比较简单, 创建 detail::type_info 并正确关联 Python 类型后, 分别向对应的 cpp 字典以及 py 字典注册该类型, 这样我们就通过相关的PyTypeObject 或者相关的 C 类型实现从 Python 侧或者 C 侧的双向查询了.


3.1.3 tb_base 的处理 - 3c 部分的实现

可能有细心的读者发现了, 上面我们创建的 Python 类型, 并没有对对象的构造和析构等做详细的处理, 虽然该部分我们会在后续的 Runtime 讲述部分做具体的展开, 不过我们先要了解信息是如何正确的关联的: 位于 class.h - make_new_python_type() 中:

代码语言:javascript复制
auto *base = (bases.empty()) ? internals.instance_base : bases[0].ptr();
// ... Some code ignore here
type->tp_base = type_incref((PyTypeObject *) base);

而没有父类声明的情况下, 通过阅读代码可知, internals.instance_base 被填充的信息是:

代码语言:javascript复制
internals_ptr->instance_base = make_object_base_type(internals_ptr->default_metaclass);

这个地方的类型级联比较复杂, 可以参考下图:

pybind11 使用层次化的结构解决类型之间的依赖关系, 不同的类型一般设置的自定义方法是不一样的. 主要的类型有以下几个: - internals::default_meta_class: pybind11 最基础的类型, 像 tp_call, tp_setattro, tp_getattro 等自定义方法是在此处绑定的. - internals::instance_base: C 对象的基础类型, 从上面的 default_meta_class 继承, 并设置了 tp_new, tp_init, tp_dealloc 用于管理 C 对象的分配, 构造以及释放 - root_classsub_class: 这两者都是在上面的 pybind11::class_ 构造时处理的, 区别是存在父类的情况, 子类的 tp_base 会被指定到父类创建的PyTypeObject, 否则我们将以 internals::instance_base 作为 tp_base.

通过这种层级式的类型设计, pybind11 就能特定层处理特定事务的方式, 为解决好 C 类型在 Python 虚拟机中的表达提供一个基础支持了. 大部分情况我们关注从 internals::instance_base 开始的实现即能比较清楚的了解 pybind11 对 C 对象的处理机制了.

[!info] 通过对相关代码的分析, 我们可以看到 pybind11 主要是通过 C 本身的 RTTI 特性支持来完成的 C 类型到 pybind11 内部维护的类型的映射. 但我们知道首先 RTTI 实现的功能较薄弱, 其次相关的设施会存在额外的运行时开销, 在 C 17 特性下, 我们一般会选择对应的编译期实现来代替相关的RTTI typeid() 以及 typeindex, 一方面是避免对 C 薄弱的 RTTI 的特性的依赖, 另外也是更多的利用编译期特性来提高整体实现的性能.


3.2 Register - C 函数注册部分

要完成 C 函数到 Python 的注册, 我们需要对 C 函数进行类型擦除, pybind11 的实现大致如下图所示:

我们一般通过使用 class_::def() 来注册相关的 C 函数, 如上面提到的示例:

代码语言:javascript复制
pybind11::class_<gbf::math::Vector3>(example, "Vector3")
  .def("Length", &gbf::math::Vector3::Length);

def() 是函数注册的入口, 对于所有注册的函数, 我们调用 def() 会得到一个统一类型的 cpp_function 对象, 而其中的静态成员函数 cpp_function::dispatcher() 则是我们类型擦除的目标, 最终我们将类型已经是 PyCFunctiondispatcher() 注册到 Python 虚拟机中, 完成整个注册过程.

dispatcher() 就是一个标准的 PyCFunction, 其声明如下: 位于 pybind11.h 中:

代码语言:javascript复制
/// Main dispatch logic for calls to functions bound using pybind11
    static PyObject *dispatcher(PyObject *self, PyObject *args_in, PyObject *kwargs_in);

接下来我们具体展开一下 dispatcher() 向 Python 的注册过程.


3.2.1 dispatcher() 向 Python 的注册

pybind11 默认支持函数的 overload, 所以注册过程也是分为两种情况: - 注册时暂无同名函数注册 -> 全新的函数注册过程 - 注册时已经存在同名函数 -> 添加新的调用到已经存在的函数调用链上 接下来我们分别来看一下这两种情况对应的实现.

全新的函数注册过程 位于 pybind11.h - cpp_function::initialize_generic() 中:

代码语言:javascript复制
/* No existing overload was found, create a new function object */
            rec->def = new PyMethodDef();
            std::memset(rec->def, 0, sizeof(PyMethodDef));
            rec->def->ml_name = rec->name;
            rec->def->ml_meth
                = reinterpret_cast<PyCFunction>(reinterpret_cast<void (*)()>(dispatcher));
            rec->def->ml_flags = METH_VARARGS | METH_KEYWORDS;

            capsule rec_capsule(unique_rec.release(),
                                [](void *ptr) { destruct((detail::function_record *) ptr); });
            rec_capsule.set_name(detail::get_function_record_capsule_name());
            guarded_strdup.release();

            object scope_module;
            if (rec->scope) {
                if (hasattr(rec->scope, "__module__")) {
                    scope_module = rec->scope.attr("__module__");
                } else if (hasattr(rec->scope, "__name__")) {
                    scope_module = rec->scope.attr("__name__");
                }
            }

            m_ptr = PyCFunction_NewEx(rec->def, rec_capsule.ptr(), scope_module.ptr());
            if (!m_ptr) {
                pybind11_fail("cpp_function::cpp_function(): Could not allocate function object");
            }

我们看到通过 rec->def->ml_meth, 我们将 cpp_function::dispacher() 绑定到了通过 PyCFunction_NexEx 创建的 PyObject 上, 这样 Python 虚拟机就能正确的对对应的C 函数进行访问了. 如果该函数是 C 类的成员函数, 那么我们还需要额外的add_class_method()将创建的 Python 函数对象与我们创建的 Python c class 类型关联:

代码语言:javascript复制
inline void add_class_method(object &cls, const char *name_, const cpp_function &cf) {
    cls.attr(cf.name()) = cf;
    //...
}

这样, 我们就能正确的在 Python 中正确的访问类的成员函数了.

添加调用到函数调用链: 这种情况就不需要再创建 Python 函数对象了, 我们正确的存储相关的C 函数并且正确的生成对应的signature, 在后续 dispatcher() 执行的时候能够的找到对应的 overload 版本, 就能正确的匹配相关的 C 函数并执行了.


3.3 Register - C ctor()注册部分

pybind11 的 cpp_function 机制比较万能, ctor 本身最后也是被转换为一个 cpp_function 进行存储和使用的. 当然, 如我们所熟知的, ctor最后是被关联到了 __init__ 对应的成员名上. 位于 pybind11.h 中:

代码语言:javascript复制
// Implementing class for py::init<...>()
template <typename... Args>
struct constructor {
  template <typename Class, typename... Extra, enable_if_t<!Class::has_alias, int> = 0>
  static void execute(Class &cl, const Extra &...extra) {
    cl.def(
        "__init__", [](value_and_holder &v_h, Args... args) { v_h.value_ptr() = construct_or_initialize<Cpp<Class>>(std::forward<Args>(args)...); },
        is_new_style_constructor(), extra...);
  }


template <typename... Args, typename... Extra>
class_ &def(const detail::initimpl::constructor<Args...> &init, const Extra &...extra) {
    PYBIND11_WORKAROUND_INCORRECT_MSVC_C4100(init);
    init.execute(*this, extra...);
    return *this;
}

通过这种实现, 在导出类的时候, 通过以下代码:

代码语言:javascript复制
pybind11::class_<gbf::math::Vector3>(example, "Vector3")
      .def(pybind11::init<>())
      .def(pybind11::init<double, double, double>());

最终的效果就是对应的.def()声明被转换到了对__init__函数的注册, 而当对象执行__init__的时候, 调用的是construct_or_initialize<Cpp<Class>>(), 这里面其实最终是根据类是否可构造调用的不同版本的 new 实现: 位于 init.h 中:

代码语言:javascript复制
template <typename Class, typename... Args, detail::enable_if_t<std::is_constructible<Class, Args...>::value, int> = 0>
inline Class *construct_or_initialize(Args &&...args) {
  return new Class(std::forward<Args>(args)...);
}
template <typename Class, typename... Args, detail::enable_if_t<!std::is_constructible<Class, Args...>::value, int> = 0>
inline Class *construct_or_initialize(Args &&...args) {
  return new Class{std::forward<Args>(args)...};
}

虽然代码层面有多次的跳转, 但这些代码在 Release 的情况应该都是能被优化的, 这种利用已有设施扩展新特性的方式, 也不失为一种有效的实现机制. 这种将 ctor() 转义为函数调用的方式, 特定场景下也有比较强的实用性.


3.4 Register - C 成员变量注册部分

同ctor(), pybind11 对属性的处理最终也是通过cpp_function来实现的: 位于 pybind11.h 中:

代码语言:javascript复制
template <typename C, typename D, typename... Extra>
    class_ &def_readwrite(const char *name, D C::*pm, const Extra &...extra) {
        static_assert(std::is_same<C, type>::value || std::is_base_of<C, type>::value,
                      "def_readwrite() requires a class member (or base class member)");
        cpp_function fget([pm](const type &c) -> const D & { return c.*pm; }, is_method(*this)),
            fset([pm](type &c, const D &value) { c.*pm = value; }, is_method(*this));
        def_property(name, fget, fset, return_value_policy::reference_internal, extra...);
        return *this;
    }

具体的Property实现使用了PyPropertyType, 借助该类型本身的机制:

代码语言:javascript复制
// rec_func must be set for either fget or fset.
    void def_property_static_impl(const char *name,
                                  handle fget,
                                  handle fset,
                                  detail::function_record *rec_func) {
        const auto is_static = (rec_func != nullptr) && !(rec_func->is_method && rec_func->scope);
        const auto has_doc = (rec_func != nullptr) && (rec_func->doc != nullptr)
                             && pybind11::options::show_user_defined_docstrings();
        auto property = handle(
            (PyObject *) (is_static ? get_internals().static_property_type : &PyProperty_Type));
        attr(name) = property(fget.ptr() ? fget : none(),
                              fset.ptr() ? fset : none(),
                              /*deleter*/ none(),
                              pybind11::str(has_doc ? rec_func->doc : ""));
    }

我们可以通过该类型的 __call__ 方法很方便的将 c 版的 get/set 方法与对应的 PyPropertyType 的 get/set 方法绑定.


3.5 Runtime - C 类对象的创建和销毁

前面我们介绍了类对象注册相关的部分, 本节我们将展开 Runtime 部分的实现, Runtime 部分也是整体导出实现的最后一环, 最终将 C 对象和 Python 对象关联到了一起.


3.5.1 pybind11::detail::instance

在 Python 虚拟机中, 所有的 C UDT 对象, 不管是不是由 Python 创建持有的, 都将表达为一个 pybind11::detail::instance 对象, 所有的 C UDT对象都会被类型擦除到 instance, 能够想象的, 这个对象在需要的时候能够还原为原始的 C 对象并操作, 所以我们在其中需要给它关联足够的 C 类型信息. 我们先来看一下 instance 的定义: 位于 common.h 中

代码语言:javascript复制
/// The 'instance' type which needs to be standard layout (need to be able to use 'offsetof')
struct instance {
    PyObject_HEAD;
    /// Storage for pointers and holder; see simple_layout, below, for a description
    union {
      void *simple_value_holder[1   instance_simple_holder_in_ptrs()];
      nonsimple_values_and_holders nonsimple;
    };
    /// Weak references
    PyObject *weakrefs;
    /// If true, the pointer is owned which means we're free to manage it with a holder.
    bool owned : 1;
    // ... some members ignore here
};

我们需要注意的是以下几点: - PyObject_HEAD宏: 该类需要被 Python 虚拟机直接使用, 需要包含该宏形成GC对象链表. - union: 一个data holder设计, simple_value 和能够被缓冲区直接装下的对象使用第一个值, 其它情况使用第二个值. - weakrefs: 弱引用字段, 通过构建类型时的tp_weaklistoffset 告知Python 虚拟机对应弱引用的偏移. - owned: 标识对象是否被 Python 虚拟机直接所有, 这种情况下 Python 虚拟机需要负责析构和释放对应对象.

细心的读者可能有疑问了, 我们似乎在上面的代码中并没有看到 C 类型信息存储的字段? 这是因为我们的 instance 始终是由 Python 虚拟机负责创建并填充额外信息的, 我们始终可以通过 Py_TYPE(this_instance) 来获取对应的 PyTypeObject, 然后从这个我们为每个 C UDT 类型唯一创建的 PyTypeObject 上就可以查询到所有我们需要的信息了.


3.5.2 pybind11::detail::value_and_holder

上面的instance其实是不利于使用的, 首先它关联的 C 对象存储的位置可能是 union 中的一项, 另外类型信息需要额外的调用才能准确获取, 所以 pybind11 在使用上包装了一个 value_and_holder 类用来解决便利性的问题, 对应的定义如下: 位于 type_caster_base.h 中:

代码语言:javascript复制
struct value_and_holder {
    instance *inst = nullptr;
    size_t index = 0u;
    const detail::type_info *type = nullptr;
    void **vh = nullptr;
    //... some thing ignore here.
};

我们可以看到所有类对象的相关信息都已经被存储为 value_and_holder 的成员变量了. 此处我们并不想过多展开 C 反射相关的细节, 所以对应的 instance -> value_and_holder的处理代码, 以及处理对象基础类型衍生出的 values_and_holders 我们就不一一展开了.


3.5.3 C 类在 Python 中的类型

我们先来回顾一下前面提到过的 pybind11 对 C 对象的类型的处理机制:

类对象的创建和销毁都涉及到了上图中的 internals::instance_base 类型, 我们先来看一下创建该类型的 make_object_base_type() 函数的实现: 位于 class.h 中:

代码语言:javascript复制
/** Create the type which can be used as a common base for all classes.  This is
    needed in order to satisfy Python's requirements for multiple inheritance.
    Return value: New reference. */
inline PyObject *make_object_base_type(PyTypeObject *metaclass) {
    constexpr auto *name = "pybind11_object";
    auto name_obj = reinterpret_steal<object>(PYBIND11_FROM_STRING(name));

    /* Danger zone: from now (and until PyType_Ready), make sure to
       issue no Python C API calls which could potentially invoke the
       garbage collector (the GC will call type_traverse(), which will in
       turn find the newly constructed type in an invalid state) */
    auto *heap_type = (PyHeapTypeObject *) metaclass->tp_alloc(metaclass, 0);
    if (!heap_type) {
        pybind11_fail("make_object_base_type(): error allocating type!");
    }

    heap_type->ht_name = name_obj.inc_ref().ptr();
#ifdef PYBIND11_BUILTIN_QUALNAME
    heap_type->ht_qualname = name_obj.inc_ref().ptr();
#endif

    auto *type = &heap_type->ht_type;
    type->tp_name = name;
    type->tp_base = type_incref(&PyBaseObject_Type);
    type->tp_basicsize = static_cast<ssize_t>(sizeof(instance));
    type->tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HEAPTYPE;

    type->tp_new = pybind11_object_new;
    type->tp_init = pybind11_object_init;
    type->tp_dealloc = pybind11_object_dealloc;

    /* Support weak references (needed for the keep_alive feature) */
    type->tp_weaklistoffset = offsetof(instance, weakrefs);

    if (PyType_Ready(type) < 0) {
        pybind11_fail("PyType_Ready failed in make_object_base_type(): "   error_string());
    }

    setattr((PyObject *) type, "__module__", str("pybind11_builtins"));
    PYBIND11_SET_OLDPY_QUALNAME(type, name_obj);

    assert(!PyType_HasFeature(type, Py_TPFLAGS_HAVE_GC));
    return (PyObject *) heap_type;
}

通过 instance_base 上的这些自定义行为方法的映射:

代码语言:javascript复制
//对象构造相关的 new 和 init 调用
type->tp_new = pybind11_object_new;
type->tp_init = pybind11_object_init;

//对象析构相关的调用
type->tp_dealloc = pybind11_object_dealloc;

诸如 C 对象创建, 以及 C 对象销毁的操作, 通过这些 instance_base 上关联的自定义方法, 都能很好实现了.


3.5.4 类对象的创建

当我们尝试在Python中构造一个 C 对象时, 如上例中的:

代码语言:javascript复制
math3d.Vector3(3, 4, 5)

与 Lua 类似, Lua 是通过 __call 这个元方法直接完成封装的, Python 此处的实现会稍微复杂一点, 需要结合一部分 Python 源码才方便理解. pybind11中整个 C 对象的构建过程如下图所示:

首先, 我们触发的其实是 math3d.Vector3 这个类型的 __call__ 自定义方法, 而这个方法其实在default_meta_class 类型创建的时候被关联到了 pybind11_meta_call 这个函数上: 位于 class.h 中:

代码语言:javascript复制
/// metaclass `__call__` function that is used to create all pybind11 objects.
extern "C" inline PyObject *pybind11_meta_call(PyObject *type, PyObject *args, PyObject *kwargs) {

    // use the default metaclass call to create/initialize the object
    PyObject *self = PyType_Type.tp_call(type, args, kwargs);
    // ... 
    retrun self;
}

真正负责对象构建的地方发生在 Python 源码部分, PyType_Type.tp_call() 调用最后会调用到 typeobject.c 中的 type_call() 函数: 位于 typeobject.c 中:

代码语言:javascript复制
static PyObject *
type_call(PyTypeObject *type, PyObject *args, PyObject *kwds)
{
    //... some code ignore here
    obj = type->tp_new(type, args, kwds);
    obj = _Py_CheckFunctionResult(tstate, (PyObject*)type, obj, NULL);
    if (obj == NULL)
        return NULL;

    /* If the returned object is not an instance of type,
       it won't be initialized. */
    if (!PyType_IsSubtype(Py_TYPE(obj), type))
        return obj;

    type = Py_TYPE(obj);
    if (type->tp_init != NULL) {
        int res = type->tp_init(obj, args, kwds);
        if (res < 0) {
            assert(_PyErr_Occurred(tstate));
            Py_DECREF(obj);
            obj = NULL;
        }
        else {
            assert(!_PyErr_Occurred(tstate));
        }
    }
    return obj;
}

我们会看到其中调用到的 type->tp_new() 以及 type->tp_init(), 最终两者被关联到的是我们上面提到的 instance_base 类型创建时绑定的 pybind11_object_new__init__, 这与 c 对象创建的逻辑基本一致, 都是先分配对象空间, 再调用对象的构造函数, 只是中间有部分代码跟 Python源码相关, 理解起来会复杂一些, 相关的自定义方法代码的实现这里也直接给出: pybind11_object_new() - 位于 class.h 中:

代码语言:javascript复制
extern "C" inline PyObject *pybind11_object_new(PyTypeObject *type, PyObject *, PyObject *) {
    return make_new_instance(type);
}

inline PyObject *make_new_instance(PyTypeObject *type) {
#if defined(PYPY_VERSION)
    // PyPy gets tp_basicsize wrong (issue 2482) under multiple inheritance when the first
    // inherited object is a plain Python type (i.e. not derived from an extension type).  Fix it.
    ssize_t instance_size = static_cast<ssize_t>(sizeof(instance));
    if (type->tp_basicsize < instance_size) {
        type->tp_basicsize = instance_size;
    }
#endif
    PyObject *self = type->tp_alloc(type, 0);
    auto *inst = reinterpret_cast<instance *>(self);
    // Allocate the value/holder internals:
    inst->allocate_layout();

    return self;
}

pybind11_object_new() 的主要作用是负责分配 PyObject 对象, 也就是我们上面介绍的 instance 对象.

__init__ 默认绑定的 pybind11_object_init(), 它的实现如下: pybind11_object_init() - 位于 class.h 中:

代码语言:javascript复制
/// An `__init__` function constructs the C   object. Users should provide at least one
/// of these using `py::init` or directly with `.def(__init__, ...)`. Otherwise, the
/// following default function will be used which simply throws an exception.
extern "C" inline int pybind11_object_init(PyObject *self, PyObject *, PyObject *) {
    PyTypeObject *type = Py_TYPE(self);
    std::string msg = get_fully_qualified_tp_name(type)   ": No constructor defined!";
    PyErr_SetString(PyExc_TypeError, msg.c_str());
    return -1;
}

它并不完成真正的 c 构造函数的调用, 仅作为一个 fallback, 在 C 构造函数匹配失败后被调用. 我们需要如前面构造函数注册提到的那样, 利用类型上注册的名为 __init__ 的函数, 来完成对象的构造.

[!note] instance 对象其实是被间接构造的, 我们告知 Python 的其实是 instance 类型的大小, 然后在 instance 的头部是 PyObject 对象 Head 宏, 所以其实我们只是拿一个内存布局与 Python 内部的 PyObject 对象完全一致的一个C 类 instance 来操作对应的内存块, 这里会比其它语言的相关实现绕一点, 侵入式比较强, 但明白了这一点就基本搞清了pybind11中 C 对象在 Python 中存在的形式, 以及为什么对 C 对象在 Python 中的创建是两个单独的函数处理后才完成的.


3.5.5 类对象的销毁

在前面提到的 C 对象在 Python 中关联的类型的创建相关的代码中:

代码语言:javascript复制
//对象构造相关的 new 和 init 调用
type->tp_new = pybind11_object_new;
type->tp_init = pybind11_object_init;

//对象析构相关的调用
type->tp_dealloc = pybind11_object_dealloc;

我们为 tp_dealloc 字段绑定了 pybind11_object_dealloc 方法, 这样相关对象在触发GC回收的时候, 会调用pybind11_object_dealloc(), 而 C 对象的析构也是在其中完成的: 位于 class.h 中:

代码语言:javascript复制
/// Instance destructor function for all pybind11 types. It calls `type_info.dealloc`
/// to destroy the C   object itself, while the rest is Python bookkeeping.
extern "C" inline void pyra_object_dealloc(PyObject *self) {
    auto *type = Py_TYPE(self);

    // If this is a GC tracked object, untrack it first
    // Note that the track call is implicitly done by the
    // default tp_alloc, which we never override.
    if (PyType_HasFeature(type, Py_TPFLAGS_HAVE_GC) != 0) {
        PyObject_GC_UnTrack(self);
    }

    clear_instance(self);
    type->tp_free(self);
    // ...
}

其中的 clear_instance(self) 调用负责完成 C 对象的析构调用, 相关的代码调用链比较长, 此处不再展开了, 我们可以简单了解, 最终是通过前面介绍的 value_and_holder 再调用到 C 类注册时 type_record 上绑定的 class_::dealloc() 完成的, 感兴趣的读者可以自行查阅相关代码.


3.6 Runtime - 函数调用

在运行时状态下, 在 Python 中调用对应的 C 函数, 入口都是前面注册部分我们提到的 cpp_function::dispatcher() 函数, 我们再通过 pybind11 的实现正确处理从 Python 传入的值, 完成其中对应的原始 C 函数的调用, 然后再通过 pybind11 的实现将返回值传递给 Python, 整个 Python 调用 C 函数的过程就完成了, 在下文类型转换相关的章节中我们会具体展开这部分的细节.


3.7 其它

除了上面说到的对 C 类的导出支持能力外, 利用 pybind11提供的设施:

代码语言:javascript复制
class Animal { 
virtual std::string speak() const { return "I am an animal."; } 

class PyAnimal : public Animal { 
public: using Animal::Animal; 
std::string speak() const override { 
  PYBIND11_OVERRIDE_PURE( std::string, // Return type 
  Animal, // Parent class 
  speak // Method name ); } 
};

我们也能很好的实现在 Python 中 override C 类的功能. 另外, 通过 pybind11 对 Python 对象的封装, 我们通过直接在 C 中与 Python 对象交互, 也能很容易的实现出 C 中使用 Python 类的功能, 下面是简单的示例代码:

代码语言:javascript复制
#pragma once
#include <string>
#include <pybind11/pybind11.h>
#include <pybind11/embed.h>

namespace py = pybind11;

class Derived {
public:
    Derived(const std::string &name) : name_(name) {
        py::module base_module = py::module::import("base");
        py::object base_class = base_module.attr("Base");
        py_instance_ = base_class();
    }

    const std::string &get_name() const {
        return name_;
    }

    std::string foo() {
        return py_instance_.attr("foo")().cast<std::string>();
    }

    std::string bar() {
        return py_instance_.attr("bar")().cast<std::string>();
    }

private:
    std::string name_;
    py::object py_instance_;
};

通过 C 内部持有一个 py::object 对象, 我们即可在需要的时候对内置的 Python 对象进行访问, 从而实现为一些核心的 Python 对象提供 C Wrapper 类的目的.


4. pybind11 中的类型转换系统

前面我们分别介绍了 pybind11 对 Python对象的封装, 以及对 C 对象的导出支持, 很多时候我们需要跨语言边界对各种不同的类型进行处理, 做 C 类型 <=> Python 类型相关的转换支持, 这部分功能是由 pybind11 中的类型转换系统来完成的, 下面我们来具体了解它的设计与实现.


4.1 type_caster

pybind11 是通过特化 pybind11::detail::type_caster<type> 来完成的对不同类型的支持. pybind11 对常规c 类型(UDT)的支持比较特殊, 不同于大部分 Traits 使用的默认实现对应的是空类型, 在 pybind11 中, 未特化处理到的类型, 即是 UDT类型, 也就是我们最开始看到的:

代码语言:javascript复制
template <typename type, typename SFINAE = void>
class type_caster : public type_caster_base<type> {};

来完成对 UDT 类型的支持, 而其他类型使用特化版本的 type_caster<> 来支持. 我们先来看一下 type_caster<> 这个Traits的公共成员:

如上图所示, 一个特化的 type_caster<> 主要包括三部分实现: 1. py_type -> type_caster<>转换到的 C 类型 2. bool load(pybind11::handle src, bool convert) -> 从PyObject加载对应pt_type的 c 值. 3. pybind11::handle cast(U src, return_value_policy) -> 从 C 类型值转换到目标 PyObject 对象. 很多时候我们会有多个版本的cast() 重载以适应不同类型的C 值的情况, 比如对于数值类型, 可能存在double, int64_t, ...等一系列子类型, 需要我们进行特化处理.


4.2 builtin 类型的处理简述

相关的实现比较多, 我们仅列举其中一部分实现:

4.2.1 数值类型

位于 cast.h 中:

代码语言:javascript复制
template <typename T>
struct type_caster<T, enable_if_t<std::is_arithmetic<T>::value && !is_std_char_type<T>::value>> {
};

具体的py_type实现, 以及对应的load(), cast()实现不详细展开了, 已知原始类型和目标类型的情况下, 相关的实现都比较好理解.

4.2.2 void, void_type, nullptr_t

位于 cast.h 中:

代码语言:javascript复制
template <typename T>
struct void_caster {
public:
    bool load(handle src, bool) {
        if (src && src.is_none()) {
            return true;
        }
        return false;
    }
    static handle cast(T, return_value_policy /* policy */, handle /* parent */) {
        return none().release();
    }
    PYBIND11_TYPE_CASTER(T, const_name("None"));
};

template <>
class type_caster<void_type> : public void_caster<void_type> {};

template <>
class type_caster<std::nullptr_t> : public void_caster<std::nullptr_t> {};

其中比较特殊的是void, 因为 void* 本身是有意义的类型, 所以void的特化有处理void* <-> PyObject 之间的相互转换.

4.2.3 bool

位于 cast.h 中:

代码语言:javascript复制
template <>
class type_caster<bool> {

4.2.4 字符串

位于 cast.h 中:

代码语言:javascript复制
// Helper class for UTF-{8,16,32} C   stl strings:
template <typename StringType, bool IsView = false>
struct string_caster {
//...
};

template <typename CharT, class Traits, class Allocator>
struct type_caster<std::basic_string<CharT, Traits, Allocator>,
                   enable_if_t<is_std_char_type<CharT>::value>>
    : string_caster<std::basic_string<CharT, Traits, Allocator>> {};

template <typename CharT, class Traits>
struct type_caster<std::basic_string_view<CharT, Traits>,
                   enable_if_t<is_std_char_type<CharT>::value>>
    : string_caster<std::basic_string_view<CharT, Traits>, true> {};

std::string 以及 std::string_view 都是继承string_caster来实现的, c-style string的实现也是借助一个内嵌的type_caster<:string>来完成的, 内置定义了一个StringCaster类型:

代码语言:javascript复制
using StringType = std::basic_string<CharT>;
using StringCaster = make_caster<StringType>;

4.3 pyobject 类型的处理

前面我们介绍了, pybind11 定义了一些辅助类型用于直接关联 Python 的各种 PyObject 类型, 在一些特定的地方, 我们可能会直接对使用到相关类型的函数或者变量进行导出, 这就会存在这种 wrapper 类型本身跟 Python 进行交互的转换的需要, 而一般这种 wrapper 类型的转换肯定是特殊的, 所以这个地方, pybind11 也需要对handle, object等类型实现特化版本的type_caster: 位于 cast.h 中:

代码语言:javascript复制
template <typename type>
struct pyobject_caster {
    template <typename T = type, enable_if_t<std::is_same<T, handle>::value, int> = 0>
    pyobject_caster() : value() {}

    // `type` may not be default constructible (e.g. frozenset, anyset).  Initializing `value`
    // to a nil handle is safe since it will only be accessed if `load` succeeds.
    template <typename T = type, enable_if_t<std::is_base_of<object, T>::value, int> = 0>
    pyobject_caster() : value(reinterpret_steal<type>(handle())) {}

    template <typename T = type, enable_if_t<std::is_same<T, handle>::value, int> = 0>
    bool load(handle src, bool /* convert */) {
        value = src;
        return static_cast<bool>(value);
    }

    template <typename T = type, enable_if_t<std::is_base_of<object, T>::value, int> = 0>
    bool load(handle src, bool /* convert */) {
        if (!isinstance<type>(src)) {
            return false;
        }
        value = reinterpret_borrow<type>(src);
        return true;
    }

    static handle cast(const handle &src, return_value_policy /* policy */, handle /* parent */) {
        return src.inc_ref();
    }
    PYBIND11_TYPE_CASTER(type, handle_type_name<type>::name);
};

template <typename T>
class type_caster<T, enable_if_t<is_pyobject<T>::value>> : public pyobject_caster<T> {};

通过 objects 相关的 type_caster<> 特化实现, 所有的 pybind11 定义的 pyobject 都能在 C <-> Python 间简单的进行转换了, 甚至在 C 函数中直接使用 pyobject 类型, pybind11 也能自动处理相关参数在 C <-> Python 间的传递, 这种抽象对于易用性的提高是非常有好处的.


4.4 常规 C 类的处理 - UDT 类型支持

前面我们也提到了, type_caster 的默认实现就是 常规 C 类对应的特化版本实现, 这个地方 pybind11 利用了 SFINAE 的正交特性, 也就是任何基于 SFINAE 支持的特化类型实现, 不能出现交集, 不能出现存在类型 A 和 B, 两者同时满足某个特化版本, 所以对于 pybind11 的 type_caster 实现来说, 如果我对其它类型都实现了特化模板, 剩下的默认匹配的模板自然就是唯一没有处理的 UDT 了: 位于 cast.h 中:

代码语言:javascript复制
template <typename type, typename SFINAE = void>
class type_caster : public type_caster_base<type> {};

/// Generic type caster for objects stored on the heap
template <typename type>
class type_caster_base : public type_caster_generic{
  //...
};

从继承关系上, 我们也能看出, UDT 涉及的类, 有 type_caster_basetype_caster_generic, type_caster_base 的作用跟下面要介绍的intrinsic_type<> 模板类是直接相关的, 内部会记录擦除修饰符的原始类型itype, 在type_caster_base这一层处理带修饰符相关的类型逻辑, 而到type_caster_generic这部分, 就只需要处理不带任何修饰符的 UDT 相关的逻辑了. 我们直接聚焦 type_caster_generic 对 UDT 的处理.


4.4.1 load()实现

位于 type_cast_base.h - type_caster_generic 类中:

代码语言:javascript复制
PYBIND11_NOINLINE bool load_impl(handle src, bool convert) {
    if (!src) {
        return false;
    }
    if (!typeinfo) {
        return try_load_foreign_module_local(src);
    }

    auto &this_ = static_cast<ThisT &>(*this);
    this_.check_holder_compat();

    PyTypeObject *srctype = Py_TYPE(src.ptr());

    // Case 1: If src is an exact type match for the target type then we can reinterpret_cast
    // the instance's value pointer to the target type:
    if (srctype == typeinfo->type) {
        this_.load_value(reinterpret_cast<instance *>(src.ptr())->get_value_and_holder());
        return true;
    }
    //...
}

这里为了表述的简便, 我们只给出了 需要load()到 C 中的 Python 对象本身的类型与使用场景预期的类型完全匹配的情况. 这种情况最后调用到了type_caster_generic::load_value(), 向 value_and_holder 对象进行填充, 具体的实现细节我们可以忽略, 此处我们只要知道 value_and_holder 是 pybind11 对所有 C UDT 对象做类型擦除的对象, 再去理解相关的实现, 就更容易理解了. 通过load()调用, 最后 Python 中保存的 C 对象, 对应的指针被存储在了 type_caster_genericvalue 成员上, 在C 函数调用等场合, 我们就能向相关的C 函数正确的传递从 Python 中传入的对应值了. 此处 value 是直接使用万能类型 void* 表达的, 因为相关的调用路径是严格限制的, 使用void*也不会导致什么问题, 只是相关代码的调试跟踪会变复杂. 其他一些像父子类的匹配等情形, pybind11 需要自己对相关的类型关系进行维护并在此处使用, 相关的代码此处不详细展开了.


4.4.2 cast()实现

位于 type_cast_base.h - type_caster_generic 类中:

代码语言:javascript复制
PYBIND11_NOINLINE static handle cast(const void *_src,
                                         return_value_policy policy,
                                         handle parent,
                                         const detail::type_info *tinfo,
                                         void *(*copy_constructor)(const void *),
                                         void *(*move_constructor)(const void *),
                                         const void *existing_holder = nullptr) {
        if (!tinfo) { // no type info: error will be set already
            return handle();
        }

        void *src = const_cast<void *>(_src);
        if (src == nullptr) {
            return none().release();
        }

        if (handle registered_inst = find_registered_python_instance(src, tinfo)) {
            return registered_inst;
        }

        auto inst = reinterpret_steal<object>(make_new_instance(tinfo->type));
        auto *wrapper = reinterpret_cast<instance *>(inst.ptr());
        wrapper->owned = false;
        void *&valueptr = values_and_holders(wrapper).begin()->value_ptr();

        switch (policy) {
            case return_value_policy::copy:
                if (copy_constructor) {
                    valueptr = copy_constructor(src);
                } else {
#if defined(PYBIND11_DETAILED_ERROR_MESSAGES)
                    std::string type_name(tinfo->cpptype->name());
                    detail::clean_type_id(type_name);
                    throw cast_error("return_value_policy = copy, but type "   type_name
                                       " is non-copyable!");
#else
                    throw cast_error("return_value_policy = copy, but type is "
                                     "non-copyable! (#define PYBIND11_DETAILED_ERROR_MESSAGES or "
                                     "compile in debug mode for details)");
#endif
                }
                wrapper->owned = true;
                break;
            //... some cases ignore here
            default:
                throw cast_error("unhandled return_value_policy: should not happen!");
        }

        tinfo->init_instance(wrapper, existing_holder);

        return inst.release();
    }

此处 pybind11 实现了多种 policy 用来控制不同的对象从 c 到 Python的转移行为, 此处我们以 copy plicy 为例来说明, 主要步骤如下: 1. auto inst = reinterpret_steal<object>(make_new_instance(tinfo->type)); 2. 获取inst关联的value_ptr 3. copy模式下对利用src对value_ptr执行拷贝构造. 4. 调用tinfo->init_instance()执行对应 Python 类型的 __init__操作 5. 释放引用计数并向 Python 返回新创建的对象(PyCapsule对象)

整体比Lua的相关实现复杂很多, 很多程度的原因是因为Python对C 对象的支持, 不是跟Lua一样使用的帮你分配指定size的内存块, 再关联meta table的做法, 从上面的代码我们可以看到, pybind11 的实现中, Python对象的创建, 和对应C 对象的构建, 是完全分开的, 并不是我们向Python虚拟机请求一块内存做 replacement new 的操作流程.

[!info] 需要注意的是, cast()的地方始终在创建新的Python对象(注意make_new_instance()任何policy的情况都会调用到), 这可能对一些频繁与 Python 交互的 C 对象并不友好, 迭代中我们需要对这部分重新进行考虑.


4.4.3 UDT 实现小结

从 C 17/20 的角度来看, 这种依赖 SFINAE 正交分解 side effect 特性来实现特定功能的方式不是特别可取, 一般我们会选择实现 is_udt<> 类似的宏来准确判断一个类型是否是我们预期的UDT, 但 pybind11 作为一个从 c 11 特性开始迭代的库, 使用这种设计, 也无可厚非, 只是对于现在的C 来说, 这种设计肯定就不推荐了.


4.5 其他类型的处理

还有剩下的对std::tuple, std::shared_ptr 等的特化, 可以借助这些对 Python C API操作各种数据类型进行快速的了解, 相关代码针对性比较强, 不同的特化用来处理不同的数据类型, 按需阅读使用即可.


4.6 函数的输入输出参数处理

pybind11 对导出功能的封装上, 对 ctor(), function call(), property 统一使用了cpp_function 的封装方式, 这点可能也是跟其他Bridge有差异的地方, 先抛开可能存在的问题, 我们先来看优点, 这样我们了解cpp_function的机制, 就了解了 pybind11 的基础运行机制, 本节中我们重点来了解一下 cpp_function 对输入输出参数的处理, 这也是对前面讲到的 C <-> Python 间类型转换的一个应用.

4.6.1 make_caster - 对&, *, &&, const等修饰符的支持

实际C 代码中, 我们很多时候使用的类型都是带修饰符的, 比如引用, 指针类型等, 在 pybind11 中, 我们通过make_caster<> 辅助模板来统一不同修饰符类型对应的caster: 位于 cast.h 中:

代码语言:javascript复制
template <typename type>
using make_caster = type_caster<intrinsic_t<type>>;

template <typename T>
struct intrinsic_type {
    using type = T;
};
template <typename T>
struct intrinsic_type<const T> {
    using type = typename intrinsic_type<T>::type;
};
template <typename T>
struct intrinsic_type<T *> {
    using type = typename intrinsic_type<T>::type;
};
template <typename T>
struct intrinsic_type<T &> {
    using type = typename intrinsic_type<T>::type;
};
//...

实现机制也比较简单, 通过递归实现的intrinsic_type<>模板类, 我们可以成功的提取类型T对应的原始数据类型. 这样通过使用的这个辅助模板类的 make_caster<> , 我们始终可以拿到原始数据类型对应的 type_caster<> 了, 也就是我们上面介绍的那些针对不同类型的特化版本的实现.

4.6.2 cpp_function 的 输入输出参数处理

cpp_function 对输入输出的处理是发生在initialize()模板函数上的, 同时该函数也完成了对 C 函数的类型擦除: 位于 pybind11.h cpp_function::initialize()中:

代码语言:javascript复制
/// Special internal constructor for functors, lambda functions, etc.
    template <typename Func, typename Return, typename... Args, typename... Extra>
    void initialize(Func &&f, Return (*)(Args...), const Extra &...extra) {
    //...

    /* Type casters for the function arguments and return value */
    using cast_in = argument_loader<Args...>;
    using cast_out
        = make_caster<conditional_t<std::is_void<Return>::value, void_type, Return>>;

    //...
}

我们本节重点关注输入输出处理的这部分, 输入参数的类型在initialize()中被定义为 cast_in, 输出的类型则是cast_out, 两者最终都是通过前面介绍的make_caster<>来关联Python<->C 类型的. 这两个类型最终通过下面的lambda来使用, 同时它也是最终所有 C 函数能够统一到 cpp_function 类型的原因: 位于 pybind11.h cpp_function::initialize()中:

代码语言:javascript复制
/* Dispatch code which converts function arguments and performs the actual function call */
rec->impl = [](function_call &call) -> handle {
    cast_in args_converter;

    /* Try to cast the function arguments into the C   domain */
    if (!args_converter.load_args(call)) {
        return PYBIND11_TRY_NEXT_OVERLOAD;
    }

    /* Invoke call policy pre-call hook */
    process_attributes<Extra...>::precall(call);

    /* Get a pointer to the capture object */
    const auto *data = (sizeof(capture) <= sizeof(call.func.data) ? &call.func.data
                                                                  : call.func.data[0]);
    auto *cap = const_cast<capture *>(reinterpret_cast<const capture *>(data));

    /* Override policy for rvalues -- usually to enforce rvp::move on an rvalue */
    return_value_policy policy
        = return_value_policy_override<Return>::policy(call.func.policy);

    /* Function scope guard -- defaults to the compile-to-nothing `void_type` */
    using Guard = extract_guard_t<Extra...>;

    /* Perform the function call */
    handle result
        = cast_out::cast(std::move(args_converter).template call<Return, Guard>(cap->f),
                         policy,
                         call.parent);

    /* Invoke call policy post-call hook */
    process_attributes<Extra...>::postcall(call, result);

    return result;
};

通过 Python虚拟机 <-> 与C 数据的交换和传递, 我们最终完成了在Python中调用一个C 函数的目的, 此处我们仅关注这其中发生的类型转换, 具体的实现先不展开.

4.6.3 pybind11 C 函数参数类型处理机制


5 异常处理

Pybind11 使得在 C 和 Python 之间传递异常变得简单。当 C 代码抛出一个异常时,Pybind11 会捕获该异常并将其转换为相应的 Python 异常。同样,当 Python 代码抛出异常时,Pybind11 也可以将其转换为 C 异常。我们通过具体的代码简单了解一下这两种情况.


5.1 Python 中处理 C 异常

这种情况下我们需要先在 C 中对对应的异常进行注册, 然后再在 python中使用它: C 代码 (exception_example.cpp):

代码语言:javascript复制
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
#include <stdexcept>

namespace py = pybind11;

int divide(int a, int b) {
    if (b == 0) {
        throw std::runtime_error("Division by zero!");
    }
    return a / b;
}

PYBIND11_MODULE(exception_example, m) {
    m.doc() = "Exception handling example";

    m.def("divide", &divide, "Divide a by b");

    // Register C   exception for translation to Python exception
    py::register_exception<std::runtime_error>(m, "CppRuntimeError");
}

Python 代码 (test_exception.py):

代码语言:javascript复制
import exception_example

try:
    result = exception_example.divide(10, 2)
    print(result)  # 输出 5
except exception_example.CppRuntimeError as e:
    print(f"Caught C   exception: {e}")

try:
    result = exception_example.divide(10, 0)
    print(result)
except exception_example.CppRuntimeError as e:
    print(f"Caught C   exception: {e}")  # 输出 "Caught C   exception: Division by zero!"

如上面代码中所示, 通过在 C 中调用 pybind11 的 register_exception<>() 模板函数注册一个异常后, 我们随后可以直接在 Python 中使用 except 直接捕获这个可能抛出的 C 异常.


5.2 C 中处理 Python 异常

这个其实就是我们一般需要在引擎中支持的脚本错误处理回调, 回调中一般会输出错误日志等信息, 通过 pybind11, 这个功能也能很好的完成:

Python 代码 (python_function.py):

代码语言:javascript复制
def add(a, b):
    if not isinstance(a, int) or not isinstance(b, int):
        raise ValueError("Both arguments must be integers")
    return a   b

C 代码 (call_python_function.cpp):

代码语言:javascript复制
#include <iostream>
#include <pybind11/pybind11.h>
#include <pybind11/embed.h>

namespace py = pybind11;

int main() {
    py::scoped_interpreter guard{}; // Start Python interpreter

    py::module module = py::module::import("python_function");
    py::function add = module.attr("add");

    try {
        int a = 2;
        std::string b = "3";
        int result = add(a, b).cast<int>();
        std::cout << a << "   " << b << " = " << result << std::endl;
    } catch (const py::error_already_set &e) {
        std::cerr << "Caught Python exception: " << e.what() << std::endl;
        e.restore(); // 重新抛出异常,这样 Python 可以提取更多信息
        PyErr_Print(); // 打印完整的 Python 错误信息
    }

    return 0;
}

上面的代码演示了如何在调用 Python 函数的时候正确的处理 Python 抛出的异常并打印相关的错误.


6. 总结

我们从 pybind11 的示例出发, 再深入到它对 Python对象的处理, 以及C 对象的处理, 再到整个 pybind11的类型系统, 讲述了 pybind11 核心功能的实现, 目的也比较简单, 深入了解它, 才能让它跟目前的项目更好的结合, 同时也给有需要的读者一个参考.

当然, 还有一些比较重要的内容, 比如: - 有 C 反射的情况下如何更好的整合 pybind11 - 主流的 Python 脚本调试方法 - 在 C 调试状态下如何便利的查看 PyObject 等 Python 虚拟机对象 - 如何支持 Python 脚本的性能分析 - ... 我们尽量在下一篇 <<记 CrossEngine 的 Python 脚本接入>> 篇中填坑.

7. 参考

  1. pybind11 GitHub
  2. OpenAI Gpt4 Web版
  3. 来自 @人丑就要多读书 的友情指导

0 人点赞