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, 你实现出来的代码可能是如下这样的:
// 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
的声明如下:
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
中:
// 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_type
和 class_
从 generic_type
继承的 class_
用于表达 C 类的各种信息, 上面示例中也可以看到: 位于 pybind11.h 中:
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 中:
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 中:
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_cpp
和 registered_types_py
中, 两者分别对应 key
为 std::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
对象信息的填充. 最后我们当然还需要将生成的类型向对应的模块注册:
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
被填充的信息是:
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_class 和 sub_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 函数, 如上面提到的示例:
pybind11::class_<gbf::math::Vector3>(example, "Vector3")
.def("Length", &gbf::math::Vector3::Length);
def()
是函数注册的入口, 对于所有注册的函数, 我们调用 def()
会得到一个统一类型的 cpp_function
对象, 而其中的静态成员函数 cpp_function::dispatcher()
则是我们类型擦除的目标, 最终我们将类型已经是 PyCFunction
的 dispatcher()
注册到 Python 虚拟机中, 完成整个注册过程.
dispatcher()
就是一个标准的 PyCFunction, 其声明如下: 位于 pybind11.h 中:
/// 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 类型关联:
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 中:
// 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 中:
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
, 借助该类型本身的机制:
// 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 中
/// 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 中:
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 中:
/** 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
上的这些自定义行为方法的映射:
//对象构造相关的 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 中:
/// 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 中:
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 中:
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 中:
/// 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 中:
/// 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 类的功能, 下面是简单的示例代码:
#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类型, 也就是我们最开始看到的:
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类型:
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 中:
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 中:
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_base
和 type_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_generic
的 value
成员上, 在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 中:
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()中:
/// 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()中:
/* 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 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. 参考
- pybind11 GitHub
- OpenAI Gpt4 Web版
- 来自
@人丑就要多读书
的友情指导