How to avoid passing generics parameters through the whole system?

TLDR: How to avoid passing generic parameters throughout the entire system (without trait objects)? Or is it the only way? How to design an extensible system without leaking implementation details?

I want to hide some platform-specific functionality. For that, I have defined a trait (at the domain level) and substituted specific types when needed:

// Represents a notification mechanism;
// hides platform-specific details.
trait Notifier {
    fn notify(&self, msg: String);
}

At the domain level, there is a mechanism that uses the Notifier trait to dispatch notifications with specific message formats:

// Manages domain-specific notifications.
// Knows what format, message, etc. need to be dispatched via the Notifier.
struct NotificationsManager<N> {
    notifier: N
}

impl<N: Notifier> NotificationsManager<N> {
    fn notify_status_changed(&self) {
        let msg = "Status has been changed...".to_owned();
        
        self.notifier.notify(msg);
    }
    
    fn notify_some_event(&self) {
        let msg = "Some Event!".to_owned();
        
        self.notifier.notify(msg);
    }
}

When working with the NotificationsManager somewhere in the system, I have to add a generic parameter:

// Without <N>, it won't work!
struct App<N> {
    notifications_manager: NotificationsManager<N>
}

I have to specify the generic parameter <N> for every struct if I need to work with NotificationsManager. This feels like leaking implementation details about NotificationsManager.

How can I avoid passing generic parameters throughout the entire system (without trait objects)? Or is that the only way? How can I design an extensible system without leaking implementation details?

What is the Rust way of designing with generic parameters? Should I use trait objects instead?

I use generics because I have read that trait objects are preferred only for two cases:

  1. To omit code bloat;
  2. When a function returns mixed types that implement some trait.

No, it's fine to use trait objects if you want to hide generics behind your public interface.

6 Likes

I would say that finding the right boundary at which to apply trait objects is the most important part of rust architecture. The type erasure they provide allows important decoupling, both logically and in allowing fast separate compilation.

Calling through a dyn Trait is only negligibly slower at the machine code level; the actual impact is in reduced optimization potential. That's why <dyn Iterator>::next tends to be something you don't want to use, because it keeps the optimizer from removing redundancy in the loop.

But various dispatchers tend not to have that problem. They need to call lots of different things, but in ways that tend not to have optimizable redundancy in the first place. And the work that then going on inside the dynamic call is typically non-trivial.

For example, it's far better for an HTTP library to keep the actual request handlers as dyns, because they're doing different things, compiling them separately is a huge compile-time win, and one dynamic call per request won't have measurable perf impact. In general, the "one generic call to something that's then optimized static calls internally" is a great way to have Rust code that's fast while not being a pain to work on.

(See also one dynamic call to the correct codec for an image type, which then runs the optimized image decoding without additional dynamic calls, say. Or one dynamic call to the negotiated crypto algorithm that then runs the optimized code for that algorithm. Or...)

17 Likes

Thank you! Your answers have helped me better understand how to design programs in Rust correctly.

This is interesting, I'd always just thought of it as "use generics wherever you can and dyn if you absolutely must."

I'm currently working through an issue where adding a variant to an enum (which wraps a struct with a generic) would have meant propagating that generic up to the parent enum and then everywhere in the code--just for this one variant of a variant! In that situation, how do you actually go about making it a trait object instead?

For example:

enum NoGenerics {
    NoGenericVariant,
    GenericVariant(InnerStruct<E>),
}

If I want to replace E with a trait object, at what level does that happen?

enum NoGenerics {
    NoGenericVariant,
    GenericVariant(Box<dyn EraseGeneric>),
}
trait EraseGeneric {}
impl<E> EraseGeneric for InnerStruct<E> {}

With this code, Rust complains that the type is not sized--fair enough--but if we add the Sized constraint on EraseGeneric then it complains it's not object safe. This is potentially a product of my actual code. It doesn't seem like it should require that Sized constraint--that's the point of the Box, and the only thing I do with it is pass it to something that requires std::error::Error.

I feel like I'm missing something simple, but it's been a long time since I've dealt with a dyn. How does this normally look?

There's no problems with the code in this post. If you paste the actual error or a closer reproduction, it'd be easier to speculate.

I apologize-- should have taken the time to make sure where the issue was. It may actually only be tangentially related to the original question. I've better located the problem, after replacing all the external types with my own. Here's the playground.

https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=231acbf9bb79e2ba5558cbe6eb4884a5

I forgot that this:
impl<T: TargetTrait> TargetTrait for Box<T> {}

is really

impl<T: TargetTrait + Sized> TargetTrait for Box<T>

and we can't impl<T: TargetTrait + ?Sized> since it's a foreign type. So how can I convince Rust that my Box<dyn Trait> does actually implement the trait? Potentially still within the bounds of the original question since it followed from the same issue for me, but happy to make another topic if not.

In real life, TargetTrait is std::error::Error, so we have some downcasting options. Afaik a lot of std traits that are implemented for Box<T> are not generally ?Sized--should they be? Is there a reason they aren't and/or a workaround?

The orphan rules don't care about bounds, and I'm not sure why you think that the presence or absence of + ?Sized makes a difference as to whether a type is foreign or not.

Also, you can implement a local trait for any type so long as you don't create an overlapping implementation (like two blanket traits).

In that particular implementation, as per the linked rules,

  • TargetTrait is local so you're good (but we'll continue for the sake of discussion)
  • The type Box<T>, but Box is "fundamental", so to tell if it's local or covered or uncovered, we need to look at T
  • T is an uncovered generic type parameter (and not a local type)

This:

-    impl<T: TargetTrait         > TargetTrait for Box<T> {}
+    impl<T: TargetTrait + ?Sized> TargetTrait for Box<T> {}

lets the playground compile.


If TargetTrait wasn't a local trait, the implementation wouldn't be allowed, with or without ?Sized in the bounds. Again, the orphan rules don't care about bounds.[1]


  1. So far anyway, they will probably care about explicitly negative bounds some day. ↩︎

1 Like

Is there a reason why #[cfg(...)]-gating doesn't work for you?

I'm sorry, I was unclear. TargetTrait is not local. I meant that we can't impl<T: TargetTrait + ?Sized> since TargetTrait is a foreign trait, not that T is a foreign type. The module ext in the playground is all external and I can't change it, I've just reproduced the relevant parts of std and other libs to exemplify the issue. I am aware that it would work if I could impl for ?Sized, as I mention in the playground.

Here's an example using std::error::Error which may make more sense. https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=81b5b3be6f82ea8c62e9d5cbed2c7ff4

I've since got it working using a wrapper trait.

https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=b6ecee34d58eb76a9c97a3700042096e

If you have any suggestions for how to improve this implementation, or if there's a better way to handle in in general, I appreciate it!

Oh! Sorry, I get it now. You can't change the implementation because it's not your code; it's the trait that's foreign. (I suggest filing a PR :slightly_smiling_face:.)

A wrapper type is an alternative to a wrapper trait.

struct TargetTraitBox(Box<dyn ext::TargetTrait>);

impl ext::TargetTrait for TargetTraitBox {}

impl std::fmt::Display for TargetTraitBox {
    fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
        use ext::TargetTrait;
        <dyn TargetTrait as std::fmt::Display>::fmt(&*self.0, f)
    }
}

impl std::fmt::Debug for TargetTraitBox {
    fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
        use ext::TargetTrait;
        <dyn TargetTrait as std::fmt::Debug>::fmt(&*self.0, f)
    }
}

Thanks for your help!

I did find this issue for anyone running into a similar situation.

In general, it works. My question was more about hiding the internal implementation from the system as a whole. That is, to hide the types of fields (and their presence in general) from other parts of the system. In other words, the principle of minimum knowledge. And due to an incomplete understanding of what to use, it turned out that all types partially knew about the internal structure of the NotificationsManager (although they don’t need it).

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.