Type coercion from trait object

Hi, I'm in need of some design help. I'm trying to have a collection of RuntimeModules which are of different types. I'm using trait objects to store in a manager Modules:

pub trait RuntimeModule {
    fn tick(&mut self, modules: &Modules, delta: f32);
}

pub struct Modules {
    modules: Vec<RefCell<Box<RuntimeModule>>>
}

impl Modules {
     //omitted for brevity...
}

struct Foo {}

impl RuntimeModule for Foo {
    fn tick(&mut self, modules: &Modules, delta: f32) { // do something }
}

struct Bar {}

impl RuntimeModule for Bar {
    fn tick(&mut self, modules: &Modules, delta: f32) { // do something }
}

fn main() {
    let mut modules = Modules::new();
    modules.add(Box::new(RefCell::new(Foo::new())), "Foo");
    modules.add(Box::new(RefCell::new(Bar::new())), "Bar");
}

So far so good. In order for the modules to communicate with each other I would like each type to be able to expose a data type, that the owner can write to (on it's tick) and other modules can read during their update. For the Foo type that would be something like:

struct FooData {
     somedata: i64
}

It could either be stored as a member in Foo or externally in the Modules manager. The other modules would retrieve using something like:

impl RuntimeModule for Bar {
    fn tick(&mut self, delta: f32) { 
        let foodata = modules.get_data<FooData>("Foo");
	// use foodata for something interesting
    }
}

This is where I'm running into problem. Due to type erasure I only have access to RuntimeModule, and since it's used as a trait object it's not possible to declare methods with generic type parameters.

I tried making an additional trait to access a modules data:

pub trait RuntimeModuleWithData {
    type Data;
    fn get_data(&self) -> Option<Self::Data>;
}

However, how do I cast from a RuntimeModule to RuntimeModuleWithData

pub fn get_data<M:RuntimeModuleWithData>(&self, identifier: &str) -> Option<M::Data> {
    // Find the module with the correct identifier
    let module: &RefCell<Box<RuntimeModule>> = self.find_entry(identifier);
    // Cast to RuntimeModuleWithData and call get_data?
}

I understand why Rust doesn't allow this, there are no contract that ensures all the modules implement the RuntimeModuleWithData trait. I can't add it to trait object, only auto traits are allowed as additional trait bounds. Is there a way of doing the type coercion without using unsafe?

As I understand it, I could use a boxed Any instead of RuntimeModule, but it would require a 'static lifetime, and I would rather like to avoid that.

I'm totally open to suggestions of completely different designs, I'm trying my best to get rid of my C++ ways :slight_smile:

2 Likes

Hi and welcome on the rust user forum. I don't have much time, but I'll try to give you some quick hints. If I understand your situation correctly, you don't really want to work with generic modules. You know exactly that there will be a Foo in there and you want to do Foo specific stuff on FooData? As opposed to doing something general on say ModuleData?

If that's the case I've got the impression the only reason to make trait objects here is to be able to store a heterogeneous collection?

If that's the case the normal way to do this would be Box<Any> and downcast. Like use a HashMap< TypeId, Box<Any> > to store your modules. What is the reason you don't want them to be 'static? Understand that 'static does not mean your object has to live for the entire lifetime of the program, just that it should not contain any non static references. As soon as it's not a reference and it doesn't have non static references, you're good.

As for cross casting, there is a crate: query_interface. I don't have much experience with it.

If I completely misunderstood your question and you do want to work in a more generic way, there are other solutions, like returning the same Data type from all runtimes, or adding get_data to the RuntimeModule trait...

I hope with that you already have some ideas helping you move forward.

When types are erased, the common behavior is either described within a trait or it is lost.

So the get_data needs to be added as a trait bound for the trait used in the trait object:

#[deny(bare_trait_objects)]

trait Foo {
    type Data;

    fn get_data (&'_ self) -> Option<&'_ Self::Data>;
}

trait Bar : Foo {} // you can use your own `Foo` trait as a supertrait

// type TraitObject = dyn Bar; // error, needs to specify Data

The issue is that you have an associated type Data. This type needs to be the same for all trait objects.

Two solutions:

  • specify that your trait object is constrained to a specific Data associated type:

    type TraitObject = dyn Bar<Data = Vec<u8>>; // OK
    
  • use another trait object (or an enum, see this thread)

    #[deny(bare_trait_objects)]
    
    use ::std::fmt::Display;
    
    trait Foo {
        fn get_data (&'_ self) -> Option<&'_ dyn Display>;
    }
    
    trait Bar : Foo {} // you can use your own `Foo` trait as a supertrait
    
    type TraitObject = dyn Bar;
    
    fn foo (x: &'_ TraitObject)
    {
        println!("{}", x.get_data().unwrap());
    }
    

Hi, thanks for the reply.

If I understand your situation correctly, you don’t really want to work with generic modules. You know exactly that there will be a Foo in there and you want to do Foo specific stuff on FooData? As opposed to doing something general on say ModuleData?

Yes, that's correct, the interesting things on FooData would be specific fields (no logic), and ModuleData was just my attempt to provide an interface for getting the data. And my plan was that asking for FooData when there is no Foo module would result in an error.

Understand that 'static does not mean your object has to live for the entire lifetime of the program, just that it should not contain any non static references.

Really? Because that is exactly how interpreted the text in the Rust book. But you are correct, that was the reason for my hesitance to use Any. Ok, if that is the case, maybe Any is the way to go.

Thanks again, should be enough to experiment some more :+1:

Technically, 'static means that the value could live for the entire life of the program, not that it will. I think the book was oversimplifying it to make it easier to understand.

for example

fn main() {
    let x = [0, 1];
    std::thread::spawn(|| {});
}

[i32; 2]: 'static even though x will not live for the entire program, but I could have the value [0, 1] live for the entire program if I needed it to.

More formally 'static has the rule for<'a> 'static: 'a, to read about sub-typing I would direct you to the Rust nomicon even if you don't ever need to write unsafe code.

https://doc.rust-lang.org/nomicon/README.html

1 Like

Hi, thanks for the reply.

The issue is that you have an associated type Data . This type needs to be the same for all trait objects.

Right, I realize now that it's not possible to solve with an additional trait with associated type like I tried.

use another trait object (or an enum , see this thread)

That's an interesting approach, I might try that after the Any approach. But if I read that code correctly you would need to know all possible types and wrap each in an enum entry, but I think that could be solved.

Technically, 'static means that the value could live for the entire life of the program, not that it will.

That is an interesting but important details about lifetimes that I haven't understood until now. Probably need to read that chapter again :slight_smile:

That's correct, but if you know all of the types beforehand (eg. you are not writing a library for which a user will supply types), creating an enum is generally better than boxing since it requires less heap allocation and type errors will be caught compile time rather than runtime.

In term of ergonomics, you will be matching the enum for the correct type rather than downcasting.

I think we should update the book with a little paragraph explaining what it means when you see 'static as trait bound. It's very confusing for beginners, since lifetimes only ever come up when talking about references, in which case it kind of means the entire lifetime of the program, further reinforced by the keyword static which obviously resembles a lot.

2 Likes

Cool, thanks all! Very enlightening, and always awesome to see the Rust community helping out beginners like me.

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.