Design Question

I've got a set of business objects - structs - I want to send thru a channel. These have no fundamental relationship - let's call them Books and Cars.

I have to type the channel. I can't use either Books or Cars, clearly. And there is no common supertype.

Which of these would you do:

  • Define a trait, Sendable, have each struct implement it, use that as the channel type
  • Define enum Sendable and make each struct a variant
  • Something Else

The second choice feels off to me - typing together disparate domain elements into an enum just because they share a single, ancillary property is not good modeling, I think.

That doesn't work unless you box it and send a Box<dyn Sendable>. The size of T for the channel must be fixed.

Plus, presumably you'll need to get the concrete/base type at the recipient. So you'll have to do the upcast-to-Any-and-then-downcast tricks.

This is what I commonly do. And in the end there are often other messages that I need to send through the same channel, so the enum is convenient.

Of course this only makes sense if there is a single recipient for these (unrelated?) messages. You may want that to provide a single ordering. Or because it is convenient. But there is always the option to create multiple channels.

7 Likes

I guess the question is, why you want to send them in a shared channel, if they are from unrelated domain? channels are part of the domain specific design, that's why they are strongly typed.

if, for whatever reason, you must use a shared channel in your design, I think then a sum type of different domains can be justified for the same reason.

3 Likes

I would look at it differently. You've got your business objects and business logic. That is where coherence of your modeling are most important.

Then you've got a channel paired with an enum with variants expressing exactly what types pass through it. That isn't modeling business logic, presumably. It is an implementation detail. But that enum models what the channel is capable of perfectly.

If that feels wrong, then as others have suggested, think about other options such as using multiple channels or otherwise decoupling how these things flow through the system.

3 Likes

You seem to be making a number of assumptions.

I have a few programs where the "business logic" (I hate that term, but the main logic of the thing anyway) accepts all its inputs from a single channel. Those inputs are from multiple other parts of the program that are managing the various I/O of the overall program. The simple idea to be to separate I/O from the main logic. So my input channel accepts an enum as a message type that can hold any of various program inputs. All this makes swapping I/O modules and testing the business logic easier.

Does that sound reasonable?

P.S. Why invent the word "Sendable" when we have a word for it already "Message"?

1 Like

the more context is provided, the less assumptions are made.


architectural design is very subjective, and for this example, I would say yes, it's perfectly reasonable.

in fact, this kind of API design (passing "fat" data through a "thin" eye of needle) is very common (and sometimes necessary) at "boundaries" of systems/components/layers, one common example is the event loops of GUI application (e.g. winit::Event::UserEvent). and to some extent, the kernel's system call can also be seen this way.

anyway, I was not saying to you should avoid merging/multiplexing different types of data through a shared channel. rather, my point was, if the design has reasons to use a shared channel, then there's nothing "wrong" about creating a sum type (a.k.a. enum) accordingly, which is in direct response to this sentence of OP:


I have no idea, maybe out of habits or conventions (just guessing).

it's common to see adjectives for interfaces in some lanagues: Observable, Disposable, Inspectable, Reducible, Enumerable, etc; while rust often uses "verbs" for traits, named after the traits' operations: Clone, Read, Drop, ToString, Into, etc., as opposed to, say, Clonable, Readable, Droppable, Convertible (ToStringable??? Intoable???). however, "marker" traits without methods don't necessarily follow this convention, such as Unpin, Sync, Sized, etc.

2 Likes

Yeah, I think it also comes from my Old School English-English vs American-English or whatever other English we have in the world today.

It does irk me often to hear Americans invent a new word for a thing where English-English already had a word for it.

Because one is a noun and one an adjective?

You've never see the convention of using adjectives for traits?

FYI, I went the trait route, using the downcasting mechanism detailed here.

There are reasons that would take a lot to get into that make this the better choice for this application.

Thanks all for the input!

1 Like

If you take the enum approach, I think Message is a good choice. If you take the trait approach, I might not call it Message, depending on the details of the trait and how it is expected to be used. This is a thought-provoking thread on naming traits: Discuss naming conventions for traits · rust-lang/api-guidelines · Discussion #28 · GitHub

2 Likes

Pragmatically, it's probably best to make an enum Message that has both, even if they're unrelated (they are related by being sent on the same channel…).

Otherwise you can send Box<dyn Any> and downcast to the right type.

2 Likes

I don't know if I sympathise with that statement. If those elements are all being handled by the receiver at the end of the channel I would argue they are all in the same domain.

I don't know if anyone mentioned this above but there is a third way. One could have multiple channels, each transporting a different type. Then use select! on the channels in the receiver thread/task. This way the structs can be defined by whoever generates them with no relation to each other. Those domain elements are then separated.

2 Likes

That was not viable here as there may be transactional happenings on the other end eventually, meaning multiple objects have to be treated atomically.

(In fact, we may be sending tuples instead of single objects. In which case... more refactoring will be needed.)

I marked your reply as the solution because people were continuing to reply and probably didn't know you considered it resolved. You can mark it unsolved or mark a different reply as the solution if you prefer.

2 Likes

Despite using Rust successfully for some years now I had to really scratch my head to get the message as trait idea working. I came up with this:

use std::any::Any;
use tokio::sync::mpsc;

trait AsAny {
    fn as_any(&self) -> &dyn Any;
}

struct Cat;
impl AsAny for Cat {
    fn as_any(&self) -> &dyn Any {
        self
    }
}

struct Dog;
impl AsAny for Dog {
    fn as_any(&self) -> &dyn Any {
        self
    }
}

#[tokio::main]
async fn main() {
    let (tx1, mut rx) = mpsc::channel(10);
    let tx2 = tx1.clone();

    tokio::spawn(async move {
        loop {
            let message: Box<dyn AsAny + Send> = Box::new(Cat);
            tx1.send(message).await.unwrap();
        }
    });

    tokio::spawn(async move {
        loop {
            let message: Box<dyn AsAny + Send> = Box::new(Dog);
            tx2.send(message).await.unwrap();
        }
    });

    tokio::spawn(async move {
        loop {
            if let Some(message) = rx.recv().await {
                let message = message.as_any();
                if let Some(_c) = message.downcast_ref::<Cat>() {
                    // Do something with cat
                }

                if let Some(_d) = message.downcast_ref::<Dog>() {
                    // Do something with dog
                }
            }
        }
    });
}

Having managed that I'm still not sure what is actually happening. Is that what was being suggested? It all seems a bit heavy weight compare to sending enums to me. Also, as it does not use enums it cannot check that all message types are handled at compile time.

In your mental model you can think of it as if you're still using an enum, but in this case the compiler is generating an (anonymous) enum, and adding some metadata to allow you to map types to/from the enum variants.

I keep looking at it and wondering where that secret enum (vtable) actually is and where it gets created.

The shame of it is that I can't replace all those if let Some(_c) = message.downcast_ref::<X>() with a match. Or am I missing something?

Correct, you can't use match.

I suppose it might be possible using some sort of new syntax, but note that there would always have to be an "else", a catch-all _ pattern. Unlike enums, there is no fixed set of concrete types, known by the compiler, that implement a dyn Trait. So the compiler could not do an exhaustiveness check, like it can with enums.

Enums define a closed set, while trait objects define an open set. For a library, elements of that open set might come from the caller's code, for example. If you want this feature -- you want the library user to be able to add elements to that set -- you can do this with trait objects, but not with enums. This is probably the most important use case for trait objects.

2 Likes

You can, in fact, write such a syntax with macro_rules:

macro_rules! match_any {
    (($val:expr) $($rest:tt)*) => {
        let __val = $val;
        match_any!(@__val $($rest)*)
    };
    (@ $var:ident $pat:pat => $block:block $($rest:tt)*) => {
        if let Some($pat) = $var.downcast_ref() {
            $block
        } else {
            match_any!(@$var $($rest)* )
        }
    };
    (@ $var:ident) => { panic!("Unexpected type") }
}

// ————

            if let Some(message) = rx.recv().await {
                match_any!{(message.as_any())
                    Cat => { eprintln!("cat"); }
                    Dog => { eprintln!("dog"); }
                }
            }

2 Likes