0

I'm new to rust and I recently ran into a problem with trait

I have a trait that is used as the source of a message and is stored in a structure as a Box trait object. I simplified my logic and the code looks something like this.

#[derive(Debug)]
enum Message {
    MessageTypeA(i32),
    MessageTypeB(f32),
}

enum Config {
    ConfigTypeA,
    ConfigTypeB,
}

trait Source {
    fn next(&mut self) -> Message;
}

struct SourceA;

impl Source for SourceA {
    fn next(&mut self) -> Message {
        Message::MessageTypeA(1)
    }
}

struct SourceB;

impl Source for SourceB {
    fn next(&mut self) -> Message {
        Message::MessageTypeB(1.1)
    }
}

struct Test {
    source: Box<dyn Source>,
}

impl Test {
    fn new(config: Config) -> Self {
        Test {
            source: match config {
                Config::ConfigTypeA => Box::new(SourceA{}),
                Config::ConfigTypeB => Box::new(SourceB{}),
            }
        }
    }

    fn do_sth(&mut self) -> String {
        match self.source.next() {
            Message::MessageTypeA(a) => format!("a is {:?}", a),
            Message::MessageTypeB(b) => format!("b is {:?}", b),
        }
    }
    
    fn do_sth_else(&mut self, message: Message) -> String {
        match message {
            Message::MessageTypeA(a) => format!("a is {:?}", a),
            Message::MessageTypeB(b) => format!("b is {:?}", b),
        }
    }
}

Different types of Source return different types of Message, the Test structure needs to create the corresponding trait object according to config and call next() in the do_sth function.

So you can see two enum types Config and Message, which I feel is a strange usage, but I don't know what's strange about it.

I tried to use trait association type, but then I need to specify the association type when I declare the Test structure like source: Box<dyn Source<Item=xxxx>> but I don't actually know the exact type when creating the struct object.

Then I tried to use Generic type, but because of the need of the upper code, Test cannot use Generic.

So please help me, is there a more elegant or rustic solution to this situation?

Papulatus
  • 677
  • 2
  • 8
  • 18
  • This code compiles fine, so I do not understand what is the problem. – Svetlin Zarev Oct 13 '21 at 12:13
  • @SvetlinZarev I guess the problem is that it's not very idiomatic to have enums whose variants track what implementors we have for a certain trait. The point of trait objects should be that we don't have to worry about who exactly is implementing it. – cadolphs Oct 13 '21 at 14:45
  • @SvetlinZarev Lagerbaer expressed what I was trying to say, that in this code I need pattern matching to determine the `config` and pattern matching to determine the `message`, and crucially, even though I have determined that the trait object is of type `SourceA`, I still need pattern matching to determine that its `next()` returns `MessageTypeA`, even after using if let pattern, there will still be a lot of useless code. – Papulatus Oct 13 '21 at 14:52
  • It is perfectly reasonable to me that a trait can have a function that returns an enum, especially if the "source" trait can have varying behavior but needs to adhere to a strict "message" format. However, if ConfigA always makes a SourceA which always returns a MessageA, and likewise for ConfigB... then it is indeed odd to mix polymorphism styles. Given what you've explained you want, I would probably just use trait objects all the way through. – kmdreko Oct 13 '21 at 17:40

1 Answers1

0

So what might be going on here is that you're mis-using the idea of trait objects. The whole idea of a trait object is that you want to write code that relies purely on its interface. As soon as you find yourself checking for the underlying type of a trait object, that should raise a red flag.

Of course in your example you're not using any sort of weird run-time type checking; instead, you're checking the type implicitly via the enums.

Still, you recognize this as problematic. First, it becomes very clumsy when you try to add another variant to your sources, because now you have to go and add that to your message and config enums as well. Second, you then have to add that handling logic to everywhere the trait object is used. And finally, the type system "lies" a bit. It seems to me that source A will only ever send messages of the first variant and source B will only ever send messages of the second variant, and that's how we're telling them apart.

So what's a way out here?

First, trait objects should be designed such that they can be used without having to know which implementation we're dealing with. Traits represent roles that structs can play, and code that uses trait objects says "I'm happy to work with anyone who can play that role".

If your code isn't happy to work with anyone who can play that trait's role, there's a flaw in the design.

It would be good to know how you are, in general, processing the messages returned by the sources.

For example, does it matter for the rest of your program that source A only ever returns integers and source B only ever returns floats? And if so, what about it is it that matters, and could that be abstracted behind a trait?

cadolphs
  • 9,014
  • 1
  • 24
  • 41