跳过正文
  1. Articles/

Python 与 C++ 的完美结合:pybind11 中的对象设计

·2802 字·6 分钟· ·
ykiko
作者
ykiko
目录

参加了 Google Summer of Code 2024,主要的任务就是为一个 Python 解释器 实现 pybind11 的兼容性接口。说是实现兼容性接口,实际上相当于重写 pybind11 了,所以最近一直在读它的源码。

可能有的读者不太清楚 pybind11 是什么,简单来说 pybind11 是一个中间件,让你可以方便进行 Python 与 C++ 代码之间的交互。比如在 C++ 中内嵌 Python 解释器,或者把 C++ 代码编译成动态库以供 Python 调用。具体的内容还请见官方文档。

最近基本把框架的大体的运作逻辑理清了。现在回过头来看,pybind11 不愧是 C++ 和 Python 绑定的事实标准,有很多巧妙的设计。它这套交互逻辑也完全可以套用到 C++ 和其它有 GC 的语言的交互上,比如 JS 和 C#(虽然现在并没有 jsbind11 和 csharpbind11 之类的东西)。最近可能我会写一系列相关的文章,去掉一些繁琐的细节,介绍其中一些共用的思想。

这篇文章主要是讨论 pybind11 对象设计一些有意思的点。

PyObject
#

我们都知道 Python 中,一切皆对象,全都是object。但是 pybind11 实际上是需要和 CPython 这种 Python 的具体实现打交道的。那一切皆对象在 CPython 中的体现是什么呢?答案是PyObject*。接下来让我们“看见” Python,理解实际的 Python 代码是如何运作在 CPython 中的。

创建一个对象实际上就是创建一个PyObject*

x = [1, 2, 3]

CPython 中有专门的 API 来创建内建类型的对象,上面这句话大概就会被翻译成

PyObject* x = PyList_New(3);
PyList_SetItem(x, 0, PyLong_FromLong(1));
PyList_SetItem(x, 1, PyLong_FromLong(2)); 
PyList_SetItem(x, 2, PyLong_FromLong(3));

这样的话,is的作用就很好理解了,就是用来判断两个指针的值是否相同。而所谓的默认浅拷贝的原因也就是因为默认的赋值只是指针的赋值,不涉及它指向的元素。

CPython 也提供了一系列的 API 用来操作 PyObject* 指向的对象,例如

PyObject* PyObject_CallObject(PyObject *callable_object, PyObject *args);
PyObject* PyObject_CallFunction(PyObject *callable_object, const char *format, ...);
PyObject* PyObject_CallMethod(PyObject *o, const char *method, const char *format, ...);
PyObject* PyObject_CallFunctionObjArgs(PyObject *callable, ...);
PyObject* PyObject_CallMethodObjArgs(PyObject *o, PyObject *name, ...);
PyObject* PyObject_GetAttrString(PyObject *o, const char *attr_name);
PyObject* PyObject_SetAttrString(PyObject *o, const char *attr_name, PyObject *v);
int PyObject_HasAttrString(PyObject *o, const char *attr_name);
PyObject* PyObject_GetAttr(PyObject *o, PyObject *attr_name);
int PyObject_SetAttr(PyObject *o, PyObject *attr_name, PyObject *v);
int PyObject_HasAttr(PyObject *o, PyObject *attr_name);
PyObject* PyObject_GetItem(PyObject *o, PyObject *key);
int PyObject_SetItem(PyObject *o, PyObject *key, PyObject *v);
int PyObject_DelItem(PyObject *o, PyObject *key);

这些函数在 Python 中基本都有直接对应,看名字就知道是干什么用的了。

handle
#

由于 pybind11 要支持在 C++ 中操作 Python 对象,首要任务就是对上述这些 C 风格的 API 进行封装。具体是由handle这个类型来完成的。handle是对PyObject*的简单包装,并且封装了一些成员函数,例如

大概像下面这样

class handle {
protected:
    PyObject* m_ptr;
public:
    handle(PyObject* ptr) : m_ptr(ptr) {}

    friend bool operator==(const handle& lhs, const handle& rhs) {
        return PyObject_RichCompareBool(lhs.m_ptr, rhs.m_ptr, Py_EQ);
    }

    friend bool operator!=(const handle& lhs, const handle& rhs) {
        return PyObject_RichCompareBool(lhs.m_ptr, rhs.m_ptr, Py_NE);
    }

    // ...
};

大部分函数都是像上面这样简单包装一下,但有一些函数比较特殊。

get/set
#

根据 C++ 之父 Bjarne Stroustrup 在《The Design and Evolution of C++》中的说法,引入引用(左值)类型的部分原因是为了使得用户能够对返回值进行赋值,让[]这样的运算符的重载变的更加自然。例如:

std::vector<int> v = {1, 2, 3};
int x = v[0]; // get
v[0] = 4;     // set

如果没有引用,就只能返回指针,那么上面的代码就得写成这样

std::vector<int> v = {1, 2, 3};
int x = *v[0]; // get
*v[0] = 4;     // set

相比之下,使用引用是不是美观的多呢?这个问题在其它编程语言中也存在,但不是所有语言都采用这种解决办法。例如,Rust 选择自动解引用,编译器在合适的时机自动添加*来解引用,这样也就不需要多写上面那个*了。但是,这两种方法对 Python 来说都不行,因为 Python 中根本没有解引用这个说法,也不区分什么左值和右值。那怎么办呢?答案是区分gettersetter

例如,如果要重载[]

class List:
    def __getitem__(self, key):
        print("__getitem__")
        return 1

    def __setitem__(self, key, value):
        print("__setitem__")

a = List()
x = a[0] # __getitem__
a[0] = 1 # __setitem__

Python 会检查语法结构,如果[] 出现在=的左边,就会调用__setitem__,否则就会调用__getitem__。实际上有挺多语言采用类似的设计的,例如 C# 的this[]运算符重载。

甚至连.运算符都可以重载,只需要重写__getattr____setattr__

class Point:
    def __getattr__(self, key):
        print(f"__getattr__")
        return 1

    def __setattr__(self, key, value):
        print(f"__setattr__")

p = Point()
x = p.x # __getattr__
p.x = 1 # __setattr__

pybind11 希望 handle 也能实现这样的效果,即在合适的时机调用__getitem____setitem__。例如:

py::handle obj = py::list(1, 2, 3);
obj[0] = 4; // __setitem__
auto x = obj[0]; // __getitem__
x = py::int_(1);

对应的 Python 代码是

obj = [1, 2, 3]
obj[0] = 4
x = obj[0]
x = 1

accessor
#

接下来就让我们重点讨论如何实现这样的效果。首先考虑operator[]的返回值,由于可能要调用__setitem__,所以这里我们返回一个代理对象。里面会把key存下来以备后续调用

class accessor {
    handle m_obj;
    ssize_t m_key;
    handle m_value;
public:
    accessor(handle obj, ssize_t key) : m_obj(obj), m_key(key) {
        m_value = PyObject_GetItem(obj.ptr(), key);
    }
};

下面一个问题就是如何区分obj[0] = 4x = int_(1),使得前面一种情况调用__setitem__,后面一种情况就是简单的对x赋值。注意到上面两种情况的关键性区别,左值和右值

obj[0] = 4; // assign to rvalue
auto x = obj[0]; 
x = 1; // assign to lvalue

如何让operator=根据操作数的值类别 (value category) 调用不同的函数呢?这就要用到一个比较少见的小技巧了,我们都知道可以在成员函数上加上const限定符,从而允许这个成员函数在 const 对象上调用。

struct A {
    void foo() {}
    void bar() const {}
};

int main() {
    const A a;
    a.foo(); // error 
    a.bar(); // ok
}

除此之外,其实还可以加引用限定符&&&,效果就是要求expr.f()的这个expr是左值还是右值。这样我们就可以根据左值和右值调用不同的函数了。

struct A {
    void foo() & {}
    void bar() && {}
};

int main() {
    A a;
    a.foo(); // ok
    a.bar(); // error

    A().bar(); // ok
    A().foo(); // error
}

利用这个特性我们就能实现上面的效果了

class accessor {
    handle m_obj;
    ssize_t m_key;
    handle m_value;
public:
    accessor(handle obj, ssize_t key) : m_obj(obj), m_key(key) {
        m_value = PyObject_GetItem(obj.ptr(), key);
    }

    // assign to rvalue
    void operator=(handle value) && {
        PyObject_SetItem(m_obj.ptr(), m_key, value.ptr());
    }

    // assign to lvalue
    void operator=(handle value) & {
        m_value = value;
    }
};

lazy evaluation
#

更进一步,我们希望这个代理对象仿佛就像一个handle一样,可以使用handle的所有方法。这很简单,直接继承handle就行了。

class accessor : public handle {
    handle m_obj;
    ssize_t m_key;
public:
    accessor(handle obj, ssize_t key) : m_obj(obj), m_key(key) {
        m_ptr = PyObject_GetItem(obj.ptr(), key);
    }

    // assign to rvalue
    void operator=(handle value) && {
        PyObject_SetItem(m_ptr, m_key, value.ptr());
    }

    // assign to lvalue
    void operator=(handle value) & {
        m_ptr = value;
    }
};

到这似乎就结束了,但是注意到我们的__getitem__是在构造函数中调用的,也就是说即使后面没用到获取到的值,也会调用。感觉有进一步优化的空间,能不能通过一些手段把这个求值 lazy 化呢?只在需要调用handle里面这些函数的时候才去调用__getitem__呢?

目前这样直接继承handle肯定是不行的,不可能在每次成员函数调用之前插入一次判断,然后决定要不要调用__getitem__。可以让handleaccessor都继承一个基类,这个基类里面有一个有一个接口,用来实际获取要操作的指针

class object_api{
public:
    virtual PyObject* get() = 0;

    bool operator==(const handle& rhs) {
        return PyObject_RichCompareBool(get(), rhs.ptr(), Py_EQ);
    }

    // ...
};

然后handleaccessor都继承这个基类,这时候accessor就可以在这里对__getitem__进行 lazy evaluation 了。

class handle : public object_api {
    PyObject* get() override {
        return m_ptr;
    }
};

class accessor : public handle {
    PyObject* get() override {
        if (!m_ptr) {
            m_ptr = PyObject_GetItem(m_obj.ptr(), m_key);
        }
        return m_ptr;
    }
};

这样并不涉及到类型擦除,只是需要子类暴露出一个接口,所以理所应当的我们可以使用 CRTP 来去虚化

template <typename Derived>
class object_api {
public:
    PyObject* get() {
        return static_cast<Derived*>(this)->get();
    }

    bool operator==(const handle& rhs) {
        return PyObject_RichCompareBool(get(), rhs.ptr(), Py_EQ);
    }

    // ...
};

class handle : public object_api<handle> {
    PyObject* get() {
        return m_ptr;
    }
};

class accessor : public object_api<accessor> {
    PyObject* get() {
        if (!m_ptr) {
            m_ptr = PyObject_GetItem(m_obj.ptr(), m_key);
        }
        return m_ptr;
    }
};

这样我们就在不额外引入其它运行时开销的情况下把__getitem__的调用 lazy 化了。

Conclusion
#

我们常说 C++ 实在是太复杂了,各种眼花缭乱的特性太多了,不同特性之间还经常打架。那换一个角度来看待,特性多,意味着用户就有更多的选择,有更多的设计空间,就能组装出上述这样精彩的设计。我想很难有另外一门语言能实现这样的效果。或许这就是 C++ 的魅力所在吧。

文章到这里就结束了,感谢你的阅读,欢迎评论区讨论交流。