月度归档:2020年03月

Rust 面向对象 FFI

说到 FFI,FFI to C 大概算得上是事实标准了。Rust 也提供了 to C 的 FFI,但如果我们想把 Rust 结构包装成对象提供给其他语言使用,事情就没那么简单了。


一种方法是完全依赖于源语言的对象模型,典型的例子是 SWIG。SWIG 是一个 C/C++ 的 Wrapper Generator,通过 parse C/C++ 头文件、结合不同的后端,可以输出不同语言的绑定。它的思路是比较简单的:C++ 中的成员方法全部包装为 extern C 的普通函数,接收 C++ 对象的指针;目标语言这边的对象只携带一个 C/C++ 对象的指针就可以了。我之前用 SWIG 做过一个 Wrapper,wrap 了 wxWidgets(一个 C++ 库)给 golang 用,比较头疼的是:

  • 内存管理。一般的 C++ 类都不会自带引用计数,这时候怎么处理裸指针就很头疼,每个函数的返回值和参数都需要(人工)分析出对象的所有权是否转移,以便决定需不需要在 Go 这一侧调用 delete。同时,如果所有权在 C++ 一侧,如何判断对象是否已经释放,仍然比较难办。如果 C++ 一侧的库完全使用智能指针还好一些,但这件事情高度自由,不可强求。
  • ABI 问题。上面提到了这种方法依赖于源语言的对象模型;但不仅如此,我们还依赖于编译器所使用的 ABI。例如,如果库是 gcc 编译的,wrapper 也需要是 gcc;甚至 gcc 的 Library ABI 的变化,也会使我们受到影响。
  • SWIG 的前端后端都是在一起的,中间表示就是 AST,除了直接加代码也无法自行扩展。如果要做 wrapper,最好是能够自己控制 wrapper generator。比如,Go 语言不支持重载,那 C++ 的重载怎么办?如果我们想要的与 SWIG 的不同、或是有自己特殊的情况需要处理,就需要自己 fork 了——这时候,所有与后端不相干、但又不得不知道的东西,就会成为负担。
  • (改 SWIG 的那个 Go 后端真的很难受

目前 Rust 似乎还没有 SWIG 这样工具,只找到一个 cbindgen,可以生成 C/C++ 头文件。C++ 头文件的话,比较有特色的是能够支持模板,但 impl 之类的还是无法放进 class 里面去。


另一种方法是,在源语言一侧,先手工包装并导出为一个标准的、跨语言的对象模型。谈到跨语言的对象模型,跟我有过交集的也就两个:一个是 COM,一个是 GObject。

先聊一下 GObject 吧,由于用过 GtkSharp,对 GObject 多少有一些了解。Rust 的 Gtk 绑定 gtk-rs 提供了方便手段,可以让我们在 Rust 里面 subclass GObject 类型,但这还是不满足我们的需求,因为它并不能支持在 Rust 中定义并导出新的类型给其他语言使用。

能够实现这个目的的,是一个叫做 gnome-class 的库。(作者居然是 Gnome 的 Co-Founder 😮)它能够生成 GObject 规则的 C 语言导出函数,同时可以生成 GIR 文件(GObject Introspection Repository)。

GIR 描述了 GObject 的类型,很多动态类型语言都有 gobject-introspection 库的支持,它能够直接读取经过编译后的 GIR(叫做 typelib)。也就是说,只要有了 GIR,理论上不再需要任何的其他步骤就可以在 Python/Ruby/Lua/Javascript 里面使用了我们的库了。

对于静态类型的语言,还是需要有代码生成的步骤的。不过一般来说,只要这门语言有 Gtk3 的绑定,多半都会有代码生成器的,比如 Rust/Haskell/D 之类的语言。

我实际试了一下这个库,目前还远远谈不上可用,但在几经 hack 之后终于能够正常在 Python 里面 Subclass 一个 Rust 的 struct 了:

Rust 一侧,我们需要把 Wrapper 写在一个 macro 里。class/virtual 什么的语法稍稍显得有点奇怪;还有一点点我 hack 的痕迹:

gobject_gen! {
    #[generate("generated/application.rs")]
    #[generate("generated/application.gir")]
    class Application: GObject {
        application: GiApplication,
    }

    impl Application {
        pub fn initialize(&self) {
            self.get_priv().application.0.borrow_mut().callbacks_mut().g_object 
                = <Self as glib::translate::ToGlibPtr<'_, *mut ApplicationFfi,>>::to_glib_none(self).0;
            self.get_priv().application.0.borrow_mut().initialize();
        }

        virtual fn on_updated(&self, _delta_time: i32) {
            println!("in application on_updated");
        }

        pub fn run(&self) {
            self.get_priv().application.0.borrow_mut().run();
        }
    }
}

生成了 GIR 之后,使用 g-ir-compiler 把它编译为 typelib,就可以直接在 Python 里面导入了:

from gi.repository import Application

class App(Application.Application):
    def __init__(self):
        super().__init__()
   
    def do_on_updated(self, delta_time):
        print("delta_time " + str(delta_time))

a = App()
a.initialize()
a.run()

至于 COM,最常见的就是 DirectX 的 API 了。可能大家觉得它是 Windows-Only 的技术,但其实 COM 作为 Object Model 来说仍然是跨平台的。微软自己有一个 com-rs,还有一个支持跨平台的 intercom。大概是因为 COM 全部都是接口导出,要比 GObject 简单一点,所以只需要在 Rust struct 上加一个 Attribute Macro 就可以了。这是一个 intercom 的例子,相比于上面的 gnome-class 简直轻松了一个档次:

pub use intercom::*;

#[com_library(Calculator)]

#[com_class(Calculator)]
struct Calculator {
    value: i32
}

#[com_interface]
impl Calculator {
    pub fn new() -> Calculator { Calculator { value: 0 } }

    pb fn add(&mut self, value: i32) -> ComResult<i32> {
        self.value += value;
        Ok(self.value)
    }
}

还是有不少语言支持 COM 的,至少 .net 上的语言都会比较容易;其他的比如 D 也支持直接与 COM 组件交互。


一大圈下来,似乎导出为 COM 用起来最方便,只是在目标语言一侧支持力度没有 GObject 那么大,也不知道实际用起来有没有坑。FFI 终究是一件很繁琐的事啊😥