Hmm, so I don't think that I'm focusing on returning trait objects, but I am focusing on something -- specifically, I'm trying to push to a design it is very easy to convert uses of impl Trait
into dynamic dispatch via dyn* Trait
. So for example I would like to be able to take code like
fn with_callback(x: impl FnOnce(u32))
and convert it into dyn* FnOnce(u32)
(which would work just fine, both inside and outside of a trait).
I think what you are hitting on @withoutboats is that Rust currently has two active conventions:
- Using pointer types that give restricted access, e.g.,
&T
or Rc<T>
, to capture properties like "a shared view on this value.
- Passing things by value and using trait bounds to abstract over things.
There is definitely tension, both ergonomic and otherwise, between these two patterns. A key example would be hashmap.get(&22)
. In the design of hashmap, we lean on &
to say "shared view to the key". But that is tied to introducing a pointer, which isn't necessarily what you want (it's not particularly useful when the value is a u32
).
The intent of the dyn
syntax today was to fit well with that first pattern, but my observation has been that it doesn't work that well in practice. This is for a few reasons, but perhaps the biggest is that people will rarely write Box<T>
to express ownership, but instead prefer just T
: often this is in the form of a function that takes an argument like f: impl Fn()
or whatever. In these instances, converting to f: &dyn Fn()
is an incompatible change that introduces an ergonomic hurdle to callers to boot (you can't write foo(|| ...)
you have to write foo(&|| ...)
).
To put it another way, in my head the "classic 3 modes" of Rust are T
, &T
, and &mut T
, but if T
is a dyn
type, it doesn't fit that model -- it requires a fourth mode, Box<T>
.
The Fn
traits are an example of the second pattern, actually, in that we don't have like one trait and use &
and &mut
to "select" from it, but instead we have a few traits. This is because many closures can only implement some subset of the functionality. It's interesting to note that if we had the trait views feature that @tmandry and I were discussing, it's possible we could have just one Function
trait with three methods (self
, &mut self
, and &
self) and instead have some closures that only implement
&Function`.
I definitely use both modalities, but over time I have found myself moving more and more towards taking a value of type T
with suitable bounds rather than taking values like &T
. This tends to be more ergonomic on my callers, for example, and it's also more flexible: if I have a impl Clone + Deref<T>
, that can be an Rc
, an Arc
, or an &T
, which is pretty nice. (I am not saying I wrote that particular combination a lot, it comes up but rarely I guess.)
I think what might be helpful is to categorize and look at the ways that dyn trait are used and see how well they fit each form and how they would be managed. I'll take a stab at that later today, perhaps, but off the top of my head here are a few obvious patterns:
- As a parameter that doesn't escape a fn (e.g., the dynamic equivalent of
fn foo(x: impl Foo)
multiple inputs.
- As a return value from a function (e.g., the dynamic equivalent of
-> impl Foo
), which is of course the async fn use case -- but it's also very commonly desired in other traits, like Iterator
adapters.
- As a "context object" that is threaded around all over the place -- this is basically a way to achieve dependency injection. For example, a lot of code within AWS uses trait objects to control the access to the network, so that they can inject a faulty network connection during testing that causes random failures all over the place.
- I'd have to go look, but I suspect that most of the code that does this uses
Arc<dyn Network + Send>
today, because you want to be able to close the data -- but I suspect that doing trait Network: Clone + Send
and dyn* Network
would be just as good, if not better.
- That said, I can imagine code wanting to start out with unique access to the
dyn Network
that is later shared. That's not particularly easy to do in today's system, though! You could take a Box<dyn Network>
, but then later you have to pass around a Arc<Box<dyn Network>>
-- not great. dyn*
would make that solution more ergonomic, but what you really want is a type like rc-box, where you distinguish "known to be 1 ref count" and allow that to be converted to "maybe N". I think that would work be totally nicely doable with dyn*
.
So, I guess the question is, what are the other use cases, where dyn* would not work nicely? I don't doubt they exist, but I don't know what they are off the top of my head.
Potential tangent ahead: The other trend I've found is that if I am going to be parameterizing a lot of things by some borrowed data, I will generally avoid using a large lifetime (like the compiler's 'tcx
) and instead prefer to thread a generic type T
around. I find it easier to think about as a code author, and it's of course more flexible too, since I can add associated types and functions. The primary downside is that it results in monomorphization. I'd like to see us have some kind of erased T
type parameters for this, but I think maybe I mentioned that already?