It sounds like you are wanting to pass around some sort of trait object to get the nice bonuses from polymorphism, but it needs to be FFI-safe. In normal Rust you'd use a Box<dyn Write>
and be done with it, but C doesn't know how to use Rust's fat pointers.
In your scenario you've already got an equivalent mechanism in the form of Linux file descriptors, but if that wasn't available the general solution I'd use is that of a "thin" trait object. You could also use a *mut Box<dyn Write>
(a pointer to a pointer to something implementing std::io::Write
), but the double indirection plus dynamic dispatch could be prohibitive.
The idea behind thin trait objects is you'll store the object's vtable right next to its data, then pass around a pointer to the vtable + data. This is quite similar to how COM and GObject work, and a less complicated form of how C++ achieves dynamic dispatch while still passing around normal pointers.
#[repr(C)]
struct Repr<W> {
vtable: VTable,
// Safety: Should only ever be accessed using vtable methods.
data: W,
}
#[derive(Copy, Clone)]
#[repr(C)]
struct VTable {
write: unsafe fn(*mut Repr<()>, &[u8]) -> Result<usize, Error>,
flush: unsafe fn(*mut Repr<()>) -> Result<(), Error>,
destroy: unsafe fn(*mut Repr<()>),
type_id: TypeId, // enables downcasting.
}
From there I'd create a FileHandle
type which wraps Repr
, pretends that the W
type is ()
, and implements our desired functionality (in this case, std::io::Write
) via the vtable functions.
pub struct FileHandle {
// Safety: you can only mutate this via `vtable` methods. No touching the
// wrapped `data`!
repr: Repr<()>,
}
impl Write for FileHandle {
fn write(&mut self, buffer: &[u8]) -> Result<usize, Error> {
unsafe { (self.repr.vtable.write)(&mut self.repr, buffer) }
}
fn flush(&mut self) -> Result<(), Error> {
unsafe { (self.repr.vtable.flush)(&mut self.repr) }
}
}
We also implement Drop
to make sure the wrapped data
is destroyed properly.
impl Drop for FileHandle {
fn drop(&mut self) {
unsafe {
(self.repr.vtable.destroy)(&mut self.repr);
}
}
}
The way FileHandle
gets constructed is key to the safety of the entire system. One of our invariants is that FileHandle
must be behind a pointer at all times because its true size is unknown at runtime. That means you'll only see variables of type Box<FileHandle>
or &mut FileHandle
.
impl FileHandle {
pub fn for_writer<W: Write + 'static>(writer: W) -> Box<Self> {
let repr = Repr {
vtable: VTable::new::<W>(),
data: writer,
};
}
}
You can populate the VTable
using "trampoline" functions which get instantiated for each W
type. See Rust Closures in FFI for more on this trampoline function technique.
impl VTable {
fn new<T: Write + 'static>() -> Self {
// Create some wrapper functions which will cast to a `T` and call into
// the corresponding method on std::io::Write.
unsafe fn write_trampoline<T: Write>(
repr: *mut Repr<()>,
buffer: &[u8],
) -> Result<usize, Error> {
as_mut_unchecked::<T>(repr).write(buffer)
}
unsafe fn flush_trampoline<T: Write>(repr: *mut Repr<()>) -> Result<(), Error> {
as_mut_unchecked::<T>(repr).flush()
}
unsafe fn destroy_trampoline<T>(repr: *mut Repr<()>) {
std::ptr::drop_in_place(as_mut_unchecked::<T>(repr));
}
VTable {
write: write_trampoline::<T>,
flush: flush_trampoline::<T>,
destroy: destroy_trampoline::<T>,
type_id: TypeId::of::<T>(),
}
}
}
/// For convenience, blindly get a reference to the `data` inside a `Repr<()>`
/// and cast it to a `&mut T`.
///
/// # Safety
///
/// The `repr` argument must actually point to a `Repr<T>`.
///
/// The `'a` lifetime must not outlive the `repr`. We're conjuring up an
/// arbitrary lifetime because it makes things more ergonomic.
unsafe fn as_mut_unchecked<'a, T>(repr: *mut Repr<()>) -> &'a mut T {
&mut *(&mut (*repr).data as *mut _ as *mut T)
}
And now you can create your mylib_init()
and a bunch of functions which will construct appropriate *mut FileHandle
s.
#[no_mangle]
pub unsafe extern "C" fn mylib_init(handle: *mut FileHandle) {
// TODO: Initialise the logger with our file handle.
}
#[no_mangle]
pub unsafe extern "C" fn file_handle_from_path(filename: *const c_char) -> *mut FileHandle {
let filename = CStr::from_ptr(filename);
let filename = match filename.to_str() {
Ok(f) => f,
Err(_) => return std::ptr::null_mut(),
};
let writer = match File::create(filename) {
Ok(w) => w,
Err(_) => return std::ptr::null_mut(),
};
let handle = FileHandle::for_writer(writer);
Box::into_raw(handle)
}
I've written up the whole thing on the playground.
Please let me know if you think there are logic errors or the code is unsound. I'm thinking of writing up an article explaining this technique in more detail if there is some interest.
I can't claim much credit for this idea. I first stumbled upon it when doing an informal audit of the anyhow
crate (my Repr
is anyhow's ErrorImpl
).