How to avoid dynamic dispatch in this scenario

In the following C++ snippet, object->x doesn't require dynamic dispatch.

#include <vector>
#include <memory>

struct HasX {
    int x;
};

struct A : HasX {
    bool other_stuff;
};

struct B : HasX {
    float other_stuff;
};

void set_x(const std::vector<std::shared_ptr<HasX>> &objects, int to) {
    for (auto &object : objects) {
        object->x = to;
    }
};

int main() {
    std::vector<std::shared_ptr<HasX>> objects {
        std::make_shared<A>(),
        std::make_shared<B>()
    };
    set_x(objects, 5);
}

I tried to implement the same in Rust and here is what I come up with. Notice that object.borrow_mut().get_x() now requires dynamic dispatch.

use std::{rc::Rc, cell::RefCell};

pub trait HasX {
    fn get_x(&mut self) -> &mut i32;
}

pub struct A {
    x: i32,
    other_stuff: bool
}

impl HasX for A {
    fn get_x(&mut self) -> &mut i32 {
        &mut self.x
    }
}

pub struct B {
    x: i32,
    other_stuff: f32
}

impl HasX for B {
    fn get_x(&mut self) -> &mut i32 {
        &mut self.x
    }
}

pub fn set_x(objects: &Vec<Rc<RefCell<dyn HasX>>>, to: i32) {
    for object in objects {
        *object.borrow_mut().get_x() = to
    }
}

pub fn main() {
    let mut objects = Vec::<Rc<RefCell<dyn HasX>>>::new();
    objects.push(Rc::new(RefCell::new(A { x: 0, other_stuff: false })));
    objects.push(Rc::new(RefCell::new(B { x: 0, other_stuff: 0.0 })));
    set_x(&objects, 5);
}

Since x has the same offset in both A and B, Is there a way to tell the compiler that all get_x implementations produce the same assembly so that it doesn't need to do dynamic dispatch? Or is there any other implementation that achieves the same behavior without requiring dynamic dispatch?

That's not guaranteed unless you use #[repr(C)].

Even with repr(C), not without some gnarly unsafe. Maybe you could downcast to the original types and do it safely that way... in almost surely a slower fashion than dynamic dispatch.

Depends on your definition of "same behavior". You could have something like

struct XAndThenSome {
    x: i32,
    more: More,
}
enum More {
    A(bool),
    B(f32)
}

And forgo the dyn types. And probably forgo the setter method to boot.

2 Likes

Using enum won't do it. In my actual use-case, More is something generic. A better example would be:

#include <vector>
#include <memory>

struct HasX {
    int x;
};

template<typename T>
struct XAndMore : HasX {
    T more;
};

void set_x(const std::vector<std::shared_ptr<HasX>> &objects, int to) {
    for (auto &object : objects) {
        object->x = to;
    }
};

int main() {
    std::vector<std::shared_ptr<HasX>> objects {
        std::make_shared<XAndMore<int>>(),
        std::make_shared<XAndMore<float>>()
    };
    set_x(objects, 5);
}

And Rust equivalent:

use std::{rc::Rc, cell::RefCell};

pub trait HasX {
    fn get_x(&mut self) -> &mut i32;
}

pub struct XAndMore<T: 'static> {
    x: i32,
    more: T
}

impl<T: 'static> HasX for XAndMore<T> {
    fn get_x(&mut self) -> &mut i32 {
        &mut self.x
    }
}

pub fn set_x(objects: &Vec<Rc<RefCell<dyn HasX>>>, to: i32) {
    for object in objects {
        *object.borrow_mut().get_x() = to
    }
}

pub fn main() {
    let mut objects = Vec::<Rc<RefCell<dyn HasX>>>::new();
    objects.push(Rc::new(RefCell::new(XAndMore { x: 0, more: false })));
    objects.push(Rc::new(RefCell::new(XAndMore { x: 0, more: 0.0 })));
    objects.push(Rc::new(RefCell::new(XAndMore { x: 0, more: || "or even closures" })));
    set_x(&objects, 5);
}

As for who actually uses "more", since XAndMore is contained in Rc, some other shared owners may use it.

Why translate HasX with a trait instead of a struct? If you rename XAndMore to HasX, the trait doesn't seem necessary:

#[derive(Default)]
struct HasX<More: ?Sized> {
    x: i32,
    _more: More,
}

impl<T: ?Sized> HasX<T> {
    fn get_x(&self) -> i32 {
        self.x
    }
}

// Arc is the better comparison to shared_ptr, but use Box to avoid distraction of shared mutation
fn set_x<T: ?Sized>(objects: &mut [Box<HasX<T>>], to: i32) {
    for object in objects {
        object.x = to;
    }
}

fn main() {
    let mut objects = vec![
        Box::new(HasX::<i32>::default()) as Box<HasX<dyn std::any::Any>>, // or some useful trait
        Box::new(HasX::<f32>::default()),
    ];
    
    set_x(&mut objects, 5);
}

Yes, I used dyn Any, but only for the non-shared part; as in C++, there are no dynamic calls in this code - except when objects is dropped, which calls the virtual destructors for each HasX. As I understand it, the original C++ code only calls the default non-virtual HasX destructor for each XAndMore<T>, which means the destructors for the Ts never get called at all. If you fix this by adding a virtual destructor to HasX, the main difference between the C++ code and the Rust code is where the vtable pointer is kept.

4 Likes

Thanks. This is exactly what I wanted. It's a perfect use-case for custom ?Sized types.