逆世界:让 C++ 走进 Python
要想实现 C 语言与 Python 之间的交互,业界已有不少成熟的解决方案。但如果希望实现 C++ 与 Python 之间的水乳交融,现有的这些解决方案却又都不那么完美:Boost.Python 失之环境复杂; Cython 对 C++ 支持有限; 易于上手的 ctypes 则干脆不支持 C++。
下面将会向大家介绍一种基于 Cython 的解决方案,可以轻松实现 C++ 与 Python 之间的跨语言多态,也算是补足了 Cython 对 C++ 支持的短板吧。
跨语言多态的问题
首先让我们来看问题:如果要把下面的 C++ 类 CppFoo 包装成一个 Python 类,应该怎么做?
class CppFoo
{
public:
virtual void fun()
{
cout << "CppFoo::fun()" << endl;
}
virtual ~CppFoo()
{
}
};
inline void call_fun(CppFoo* foo)
{
foo->fun();
}
我们可以使用 Cython 提供的 C++ 绑定机制,直接将 CppFoo 类包装成 Python 中的 foo.PyFoo
# 在 Cython 中引入 C++ 类定义
cdef extern from "CppFoo.hpp":
cdef cppclass CppFoo:
void fun()
void call_fun(CppFoo* foo)
# C++ 类 CppFoo 的 Python 包装类
cdef class PyFoo:
cdef CppFoo* _this
def __cinit__(self):
self._this = new CppFoo()
def __dealloc__(self):
del self._this
# 转发调用
def fun(self):
self._this.fun()
# C++ 函数 call_fun() 的 Python 包装
cpdef py_call_fun(PyFoo foo):
call_fun(foo._this)
用 Cython 将上面的文件编译成 Python 扩展 foo 后,让我们来看看测试结果:
import foo
base = foo.PyFoo()
base.fun()
# 输出 "CppFoo::fun()"
foo.py_call_fun(base)
# 输出 "CppFoo::fun()"
我们可以看到 C++ 成员函数被 Python 正确地调用了。
接着让我们更进一步:如果需要在 Python 中继承 PyFoo 并且改写 CppFoo::fun() 虚函数又会发生什么呢?
class PyDerivedFoo(foo.PyFoo):
def fun(self):
print 'PyDerivedFoo.fun()'
derived = PyDerivedFoo()
derived.fun()
# 正确输出 'PyDerivedFoo.fun()'
foo.py_call_fun(derived)
# 哎!为什么输出了 "CppFoo::fun()"?
看到了吗?我们在 Python 中改写的 PyDerived.fun() 被忽略了,py_call_fun() 调用的仍然是 C++ 父类的实现。看来 Cython 并不支持跨语言多态。
解决跨语言多态问题 如何将跨语言多态引入 Cython 中呢?谚云:额外间接层解决一切。我们可以通过增加一层中间代理来连接 C++ 和 Python 的多态机制,从而实现跨语言多态。
首先让我们明确一点,C++ 的虚函数只能在 C++ 继承类中被改写。那么我们的代理类顺理成章的应该要继承 CppFoo。
class CppFooProxy : public CppFoo
{
public:
void fun();
};
我们还需要改写代理类的 fun() 函数,让它转去调用 Python 对象的 fun() 方法,从而完成跨语言多态。
void CppFooProxy::fun()
{
if (has_python_override_method(self, "fun")) {
return call_python_method_fun(self);
}
else {
return CppFoo::fun();
}
}
在上面的代码中,我们先通过 has_python_override_method() 函数来判断 Python 对象是否改写了 fun() 方法。如果我们检测发现 Python 对象确实含有 fun() 方法,我们就将调用转发到 Python 中重新定义的那个 fun 方法上。反之,如果 Python 对象并没有改写 fun() 那就转去调用父类的默认实现 CppFoo::fun()。最终实现跨语言多态。
这里还有个特殊情况没有在代码中表现出来:如果父类方法是纯虚函数,而 Python 也没有提供任何实现,那要怎么办呢? 简单的处理方案可以直接抛出异常来报错,让纯虚函数跨界调用在运行时出错。
上面这段程序里的 self 又是什么呢? 它是一个实实在在的 Python 对象。通过 self, 我们可以在 C++ 的世界中操作彼端 Python 世界里的那个对象
class CppFooProxy : public CppFoo
{
public:
CppFooProxy(PyObject* self)
: self(self)
{
assert(self);
// 增加 Python 对象引用计数
Py_XINCREF(self);
}
~CppFooProxy()
{
// 减少 Python 对象引用计数
Py_XDECREF(self);
}
void fun();
private:
PyObject* self;
};
那么 has_python_override_method() 该如何实现呢? 我们可以用 Python 提供的 C API 直接在 C++ 代码中实现这个功能。但这里我们选择用 Cython 来实现,然后通过 Cython 的 public api 机制暴露 C 接口再给 C++ 调用。这样的好处是我们可以很简洁地用类似 Python 语法实现这个功能。
import types
cdef public api bool has_python_override_method(
object self,
const char* method_name):
method = getattr(self, method_name, None)
return isinstance(method, types.MethodType)
getattr() 方法能通过名字找到对象中相应的属性对象。在尝试获得 self 中与方法名想同名称的子对象后,我们再判断这个子对象的类型是不是一个方法。
下面 call_python_method_fun() 的实现就更简单了,一旦找到方法我们就直接转发调用
cdef public api void call_python_method_fun(object self):
method = getattr(self, method_name)
method()
搞清了 CppFooProxy::fun() 的实现细节后,下一步就是看如何将 Python 对象 self 塞进 CppFooProxy 中
from cpython.ref cimport PyObject
# 在 Cython 中引入 C++ 类 CppFoo 的定义
cdef extern from "CppFoo.hpp":
cdef cppclass CppFoo:
pass
void call_fun(CppFoo* foo)
# 在 Cython 中引入 C++ 类 CppFooProxy 的定义
cdef extern from "CppFooProxy.hpp":
cdef cppclass CppFooProxy(CppFoo):
void fun()
# 改变我们的 Python 包装类
cdef class PyFoo:
cdef CppFooProxy* _this
def __init__(self):
# 将 self 放入 CppFooProxy 中
self._this = new CppFooProxy(<PyObject*>(self))
def __dealloc__(self):
del self._this
def fun(self):
self._this.fun()
# C++ 函数 call_fun() 的 Python 包装
cpdef py_call_fun(PyFoo foo):
call_fun(foo._this)
可以看到,我们先把要包装导出的 C++ 目标类 CppFoo 和我们刚刚实现的代理类 CppFooProxy 的定义导出到 Cython 中,再构造 Python 类 PyFoo 来包装我们的代理类 CppFooProxy。PyFoo 在内部维护了一个 CppFooProxy 代理类的对象,而 PyFoo.foo() 调用会被转发到代理类的 CppFooProxy::fun() 函数上。当创建 CppFooProxy 对象时,PyFoo 也会将自己通过 self 传入到 CppFooProxy 中。这样一来,PyFoo 与 CppFooProxy 就彼此拥有对方。他们一起合作来完成 C++ 和 Python 这两个世界的连接。
细心的朋友可能意识到了,上面 foo() 函数调用转发隐藏着一个问题。PyFoo.fun() 会去调用 CppFooProxy::fun(),而 CppFooProxy::fun() 又会去调用 Python 对象中的 fun() 方法,这不是一个死循环吗? 幸运的是在 has_python_override_method() 中,我们是用 types.MethodType 来做比较,去判定对象是否改写了 fun() 方法。而 types.MethodType 只会匹配纯 Python 方法,它不包含内建函数 (built-in functions)。我们知道,Python 扩展中的方法类型都是属于内建函数类型。这样恰好排除掉了 PyFoo 自己那个属于内建函数的 fun() 方法,从而避免了危险的死循环。
至此,我们的 C++ 类 CppFoo 就成功地通过 PyFoo 类转移到了 Python 世界中了。来检验一下成果吧:
derived.fun()
# 输出 'PyDerivedFoo.fun()'
foo.py_call_fun(derived)
# 同样输出 'PyDerivedFoo.fun()'!
一切正常,在 CppFooProxy 这个额外的间接层牵线搭桥下,C++ 和 Python 终于实现了跨语言多态。
自动代码生成
问题虽然解决了。但回头看看,为了包装上面例子中的 C++ 类,我们要做的事情太多:
* 定义 C++ Proxy 类
* 实现 C++ Proxy 类和相关的虚函数
* 在 Cython 中实现相关的 Python 方法的检测和转发功能,以供 C++ Proxy 类使用
* 在 Cyhton 中引入 C++ 类定义
* 在 Cyhton 中引入 C++ Proxy 类定义
* 在 Cython 中把 C++ Proxy 类包装成 Python 扩展类
这还只是包装导出 1 个类的 1 个方法。假设有 10 个类,100 个方法需要包装导出,这工作量想想就头疼。虽说这里面并没任何技术难度,我们只要照葫芦画瓢就好了。但如果靠人手工来做的话,因为步骤繁琐会很容易出错。
对程序员这种一心偷懒的生物来说,类似的重复工作都是写个程序来自动完成。下面介绍下我写的 cppython 工具,它就是干这活儿的。
还是上面的例子,让我们来包装导出 CppFoo 类。这次我们通过 cppython 来生成所有的包装导出代码:
$ python cppython.py cpp_foo.hpp out/foo
generating out/cpp_foo.pxd ...
generating out/foo.pyx ...
generating out/foo_cppython.cpp ...
generating out/foo_cppython.hpp ...
generating out/foo.pxi ...
generating out/foo_cppython.pxd ...
generating out/setup.py ...
done.
$ cd out/ && python setup.py build_ext --inplace
可以看到,cppython 通过解析 cpp_foo.hpp 自动生成了 7 个文件
cpp_foo.pxd 将 CppFoo 类定义引入 Cython
foo_cppython.hpp 是 C++ 代理类的定义
foo_cppython.cpp 是 C++ 代理类的实现
foo_cppython.pxd 将代理类的 C++ 定义引入 Cython
foo.pyx 包含 python 扩展类 Foo 的定义
foo.pxi 包含代理类所需要的 Python 对象交互方法实现
setup.py 编译 Python 扩展模块的启动脚本
这下好了,一声令下,程序就乖乖帮我们完成了繁琐机械的工作。偷懒改变世界啊!
当然,把复杂的 C++ 类框架丝毫不差地一一映射到 Python 并不现实,也没有必要。毕竟 Python 和 C++ 各自有不同的惯用模式和编程习惯。建议在使用 cython 和 cppython 之前,先把 C++ 类的模块功能做一定的切分和包装,有选择的导出到 Python,这样效果会更好。