Use traits to define common methods?
I'm no kind of experienced rust programmer, but I was surprised that common functionality (available, push, try_push) wasn't factored into traits (maybe QueueStats, LimitedQueue and UnlimitedQueue) to allow easily writing generic functions to e.g. get the current queue stats.
(The actual usecase I had was await_empty which I realise will be fixed when #4 lands.)
If this makes sense and you'd merge the PR I'll try to find time to factor the interface out.
Those traits make a lot of sense. Sure, go ahead. I'll gladly merge a PR adding generic traits. :+1:
Throwing up a diagram here for discussion; feel free to quote and edit:
classDiagram
class Queue~T~ {
<<trait>>
len() usize
is_empty() bool
available() isize
push(T)
pop() T
try_pop() Option~T~
}
note for BoundedQueue "`Result_Unit_T` is a `Result<(), T>`"
class BoundedQueue~T~ {
<<trait>>
capacity() usize
is_full() bool
try_push(T) Result_Unit_T
}
class `unlimited::Queue`~T~ {
new() Self
}
`unlimited::Queue` --|> Queue
class `limited::Queue`~T~ {
new(usize) Self
}
`limited::Queue` --|> BoundedQueue
`limited::Queue` --|> Queue
class `resizable::Queue`~T~ {
new(usize) Self
resize(usize)
}
`resizable::Queue` --|> BoundedQueue
`resizable::Queue` --|> Queue
Do you think that len(), is_empty(), and available() should be extracted into a trait QueueStats? Since all the queue types impl Queue<T>, when would you need the more restricted QueueStats?
Looking at your UML diagram I think only one generic trait is needed.
How about the following behavior for the unlimited queue:
-
is_fullalways returnsfalse -
try_pushalways succeeds -
capacityalways returnsusize::MAX
Capacity is the only outlier here. Alternatively capacity() could return an Option<usize> which is None for the unlimited queue.
I don't like returning Option<usize> because it would be a breaking change, just to add traits, and it's annoying to have to unwrap the capacity on every call.
I like your usize::MAX suggestion, because it's comparison and math operator-friendly. The only footgun I could see...
(ramblings on wrapping addition)
is if someone wrote code for a resizable queue and ran it against an unlimited queue, tried to add a number to `usize::MAX` as the new capacity, and it resulted in wrapping addition under a release binary. You'd get a data loss bug, because you'd unexpectedly truncate the queue. BUT, you'd have to be working with a queue sized close to `usize::MAX`, which seems highly unlikely with this library.
The trait and the impl on the struct don't have to match. As GenericQueue is a new trait it wouldn't even be a breaking change.
It would be somewhat confusing though if there was a queue.capacity() and <queue as GenericQueue>::capacity() that return different types.
So yes... using usize::MAX is probably the smartest choice here.
Been sketching an implementation of this, and remembering that async functions in traits imply having to make some choices:
- Adopt a MSRV of 1.75 for the next release and use the new async trait support exclusively.
- Support a wider range of MSRV by using dtolnay/async-trait exclusively, accepting the performance trade-off of dynamic dispatch that comes with it.
- Add two new dependencies (one of them dev-only, at least) and use both dtolnay/rustversion to detect whether to use post-1.75 native async in traits support or fall back on async_trait.
I'd either go with 1 or 2. Switching between async-trait and return impl trait is an incompatible change. Detecting the Rust version and switching between those two implementations would result in a breaking change when the user switches between Rust versions.
In deadpool I just made the switch to MSRV 1.75 and removed async-trait as dependency. Though I'm not 100% sold yet as there are severe limitations. Dynamic dispatch is not supported and the resulting trait is not object safe. Thus you can't create a method expecting a &dyn GenericQueue. Though &impl GenericQueue does work as this one doesn't use dynamic dispatch and desugars to <T>(&T).
In deadpool this is a non-issue as Pool already contains the Manager as generic.
With the generic queue interface I do wonder what you need it for. What's the actual use-case for this trait anyways?
My original usecase is moot anyhow: I implemented await_not_empty as
async fn await_not_empty<T>(queue: Arc<UnlimitedQueue<T>>) {
while queue.available() < 0 {
sleep(Duration::from_millis(100)).await;
}
}
which is a hack anyway. I have both limited and unlimited queues, so I needed two impls rather than just making the function generic over GenericQueue. The only other use I can think of atm---as a side effect of picking one trait over two---is swapping in an unbounded queue without refactoring, but that's only really something you'd do/I've done in prototyping (to remove a constraint temporarily and see how the system grows).
If you just want to be able to switch between two queue implementations you can create your own module and do a pub use deadqueue::unlimited::Queue; in there. I usually tend to do a type FooQueue = deadqueue::unlimited::Queue<Foo> and use FooQueue in my code.
btw. I'll release a new version of deadqueue tomorrow so you can replace the polling code by using the new functions introduced in #4.