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:
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:
To omit code bloat;
When a function returns mixed types that implement some trait.
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...)
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?
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?
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.
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> {}
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]
So far anyway, they will probably care about explicitly negative bounds some day. ↩︎
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.
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).