Seeking Clarification on FFI Implementation Between Rust and C++ with Vtable Approach

Greetings.

I am currently exploring the Foreign Function Interface (FFI) implementation between Rust and C++, specifically in the context of using a Rust struct from the C++ side. Assuming I have a Rust struct named SomeClient with the following implementation:

pub trait Event{
    fn event_name(&self) -> &str;
}

pub trait EventHandler{
    fn event<T:Event>(&self, event: &T)->bool;
}

pub struct SomeClient;

impl SomeClient{
    pub fn add_event_listener(&self,handler:impl EventHandler)->bool{
        //...some logic
        todo!()
    }
}

As you can see, the implementation relies on Rust generics, which complicates its usage from C++. I've come across a potential solution that involves manually implementing a vtable from the C++ side, as shown below:

class Event {
public:
    virtual ~Event() = default;
    virtual const char* name() const = 0;
};

class Listener {
public:
    virtual ~Listener() = default;
    virtual void eventHandle(const Event& event) = 0;
};

extern "C" {
    typedef const char* (*GetNameFunc)(void*);
    typedef void (*DestructorFunc)(void*);

    void c_function(Event* event, GetNameFunc getName, DestructorFunc destructor) {
        //build EventObj from rust function
    }
}

//wrapper for Event*
void pass_to_c_function(Event* event) {
    auto getName = [](void* ptr) -> const char* {
        return static_cast<Event*>(ptr)->name();
    };
    auto destructor = [](void* ptr) {
        delete static_cast<Event*>(ptr);
    };

    c_function(event, getName, destructor);
}

On the Rust side, the corresponding code is:

#[repr(C)]
pub struct EventVtable{
    get_evt_name:*const unsafe extern "C" fn (*const c_void)->*const c_char
}

#[repr(C)]
pub struct EventObj{
    ptr:*mut c_void,
    vtable:*const EventVtable,
    deleter:*const unsafe extern "C" fn(*const c_void),
}

//implement traits
impl Event for EventObj{
    fn event_name(&self) -> &str {
        let s=unsafe{
            let chars=(*(*self.vtable).get_evt_name)(self.ptr);
            CStr::from_ptr(chars)
        };
        //do not mind the `unwrap`, this is only a demo
        s.to_str().unwrap()
    }
}

impl Drop for EventObj{
    fn drop(&mut self) {
        unsafe{(*(self.deleter))(self.ptr)}
    }
}

#[repr(C)]
pub struct EventHandlerVtable{
    handle_event:*const unsafe extern "C" fn(*const c_void,*const c_void)->c_int,
}

#[repr(C)]
pub struct EventHandlerObj{
    ptr:*mut c_void,
    vtable:*const EventHandlerVtable,
    deleter:*const unsafe extern "C" fn(*const c_void),
}

//implement traits
impl EventHandler for EventHandlerObj{
    fn event<T:Event>(&self, event: &T) -> bool {
        unsafe{
            (*(*self.vtable).handle_event)(self.ptr,event as *const T as *const c_void)
        }.eq(&1)
    }
}

impl Drop for EventHandlerObj{
    fn drop(&mut self) {
        unsafe{(*(self.deleter))(self.ptr)}
    }
}


//for cpp side

type EventNameFn = unsafe extern "C" fn(*const c_void) -> *const c_char;
type EventHandlerFn=unsafe extern "C" fn(*const c_void,*const c_void)->c_int;
type Deleter=unsafe extern "C" fn(*const c_void);

//build vtable obj
pub unsafe extern "C" fn build_event_obj(ptr:*mut c_void,c_func:*const EventNameFn,deleter:*const Deleter )->EventObj {
    let vtable=EventVtable{
        get_evt_name: c_func,
    };
    let vtable_ptr=Box::leak(Box::new(vtable)) as *const _;
    EventObj{
        ptr,
        vtable:vtable_ptr ,
        deleter,
    }
}

//...other builder functions

//add listener
pub unsafe extern "C" fn add_listener(ptr: *mut c_void,listener:EventHandlerObj){
    let client=ptr as *mut SomeClient;
    client.as_ref().unwrap().add_event_listener(listener);
}

This approach simplifies the interaction between Rust and C++ by implementing some virtual methods. Moreover, the FFI code in both C++ and Rust can be easily generated using macros or a code generator.

However, I've noticed that few existing FFI tools (such as cxx) adopt this approach. I'm curious to understand why. One potential downside I can think of is the performance loss due to vtable calling. Are there other drawbacks that I might be overlooking?

Lastly, I would like to note that my expertise lies more in Rust than in C++. If there are any misunderstandings or beginner mistakes in my C++ code, I kindly ask for your patience and guidance.

Thank you in advance for your time and insights.

a class with virtual member functions already has a vtable, you can use it directly from rust if you are careful enough and you know how C++ abi works. I think the windows crate has some macros to deal with COM interops, I can't remember the details.

also, the C++ std::function is a beast in itself. it's not a simple function pointer, so your implementation probably is incorrect.

1 Like

found them, they are windows-interface and windows-implement, unfortunately, there's no documentation.

btw, the vtable struct for the IUnknown C++ type is define as:

Thank you for your guidance! I've updated my code by replacing std::function with a lambda expression

your code contains a couple of errors or bad designs.

you probably misunderstood function pointers in rust. that *const marks get_evt_name "a pointer to a function pointer", which is different from the C++ function pointer.

if the vtable is not passed from C++ but instead constructed on the rust side, why stick a raw pointer there, as opposed to use Box directly, or better yet, just use an unboxed EventVtable?

also, for OO languages with classes, vtables are typically on a per type basis, not per object basis. on the other hand, prototype based object systems, tends to store the function directly in the object themselves (or somewhere along the prototype chain) without vtables. your code seems to create a vtable for each object, what's the benefit you are expecting?


some comments

besides the apparent coding error, I really don't get your point. I can't figure out what problem your code is trying to solve. your code is overly complicated and create more trouble than what it would solve (if it actually solves any problem at all).

there are many ways you can pass a C++ object to rust, your code is one of the most convoluted ways to do it. for the most part, the C++ compiler already generated the vtable for you, it makes absolute no sense to create a wrapper function poiner (via a captureless closure) for each virtual member function, and pass the wrapper to rust, then in the rust side create another wrapper object with another vtable.

2 Likes

The vtable is part of the C++ ABI, not the C ABI. The C ABI is written in stone for each major tier architecture; the C++ ABI isn't. There was a time when both GCC and Visual C++ changed the C++ ABI every couple years or so. Both haven't changed in a while, but that's not guaranteed. (RedHat is a major reason for that on the Linux side, but they're burning major bridges right now.)

COM's vtable format is written in stone, which makes it a special case.

I totally agree. actually, by "... if you know how C++ abi works", I mean exactly this: you are targeting a specific C++ toolchain when inerop directly with C++ (as opposed to going through C), it's not portable, and will never be.

I remember those days. ABI is vendor (OS and Compiler) specific. most common known is __cdecl vs __stdcall, but there are also __fastcall (but, e.g. gcc fastcall is different from msvc fastcall). don't forget __thiscall used by msvc, and there's even pascal convention from borland, etc. this blog is good read:

nowadays, I can't speak for all archtectures, but for x64 on PC at least, things have been largely settled, there's only the Common Vendor ABI (a.k.a. the Itanium ABI) vs the Microsoft ABI in practical use, and both have been stable for very long.

for gcc and clang (excluding clang-cl.exe), to quote from gcc's ABI policies and guidelines:

From GCC version 3 onwards the GNU C++ compiler uses an industry-standard C++ ABI, the Itanium C++ ABI.

for msvc, to quote from their C++ binary compatibility page:

The Microsoft C++ (MSVC) compiler toolsets in Visual Studio 2013 and earlier don't guarantee binary compatibility across major versions. ... We've changed this behavior in Visual Studio 2015 and later versions. The runtime libraries and apps compiled by any of these versions of the compiler are binary-compatible.

rust on Windows have both -msvc and -gnu toolchains. still, you have to deal with the differences, but at least there's no more __stdcall or __thiscall for x64.

there was effort to define a portable ABI for C++ many years ago, see wg21 n4028 written by Herb Sutter, but I don't see it would ever be resolved.

1 Like

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.