I'm the maintainer of the rtrb crate, which provides a wait-free SPSC (single producer, single consumer) ring buffer that's suitable for real-time use case like audio processing.
For the simplest case, it provides very straightforward Producer::push()
and Consumer::pop()
methods for writing and reading single items (of some type T
).
In addition, it also provides methods for writing/reading chunks of multiple items (like, e.g., chunks of f32
audio samples as provided by the sound card). Since the underlying data structure is a circular buffer, those chunks sometimes happen to "wrap around", which means that the beginning of the chunk is stored at the very end of the underlying buffer and the rest of the chunk is stored starting at the very beginning of the buffer. This is natural and expected, but if I know that I'm always using the same chunk size, I can choose the ring buffer length in a way that this "wrap around" never happens within a chunk.
So far so good, there is a RingBuffer::with_chunks(chunks, chunk_size) method which allows me to define a ring buffer with a size that's an integer multiple of my chunk size. However, there are two drawbacks that I don't like about this:
- Even though I as a user know that my chunks are always the same size, the compiler doesn't know that. The "chunk" methods still have to provide two slices of data, even though I know that the second one is always empty (because there is no "wrap around").
- The constructor has two unsigned integer arguments, which might be easily confounded.
As a solution to those two issues I've come up with a pull request and I'd like to hear your opinions on that:
https://github.com/mgeier/rtrb/pull/50
I've split the with_chunks()
constructor into two parts to make clear what each of the two numbers mean: RingBuffer::with_chunks(...).of_size(...)
. I guess this is somehow related to the "builder" pattern, but it isn't a typical case, because there are always exactly two arguments in exactly the same order.
So my first question: is this a bad idea?
I could just as well keep the more conventional (?) two-argument form RingBuffer::with_chunks(..., ...)
. Are there any further suggestions?
In a future version of Rust there might be named arguments, which would help here, but this might never happen. And I would like to have something that works now.
The next question is about the return type of this constructor. Instead of returning a RingBuffer
, it returns a pair of producer and consumer struct
s, which are typically passed on to different threads. What makes this a bit unconventional is the fact that not both sides have to use a fixed chunk size. It's perfectly reasonable to have one side use .push()
or .pop()
with single elements, while the other side uses fixed chunks. Therefore, there are three possible combinations of return types:
(ChunkProducer, Consumer)
(Producer, ChunkConsumer)
(ChunkProducer, ChunkConsumer)
In order to achieve this, I've created a trait
for the return value. This trait has to be public, but its name is not important for the user and therefore it is not documented.
So my second question: is this a bad idea?
As I have currently implemented it in my PR, the concrete return types are inferred from assignments to variables with known types or they can be specified with type annotations. I guess it would also be possible to provide multiple constructors with different names that each return a concrete pair of types. I guess this would be more conventional, but I couldn't come up with good names for those constructor functions and I somehow like the generic return type, but it may be a bit too fancy? Is there an alternative way to solve this?
Finally, to complete the API, there is one constructor for the "simple" case where no fixed chunks are used. This is implemented as RingBuffer::new(capacity) -> (Producer, Consumer)
. This is definitely unconventional, because I have to appease Clippy with #[allow(clippy::new_ret_no_self)]
.
So my third question: is this a bad idea?
Clippy would be silent if I chose a different name for the constructor function, any suggestions?
In the current API (before the aforementioned PR), I've worked around the issue by using the signature RingBuffer::new(capacity) -> RingBuffer
. The returned RingBuffer
then has a .split()
method which in turn returns a pair of (Producer, Consumer)
.
However, with my new PR there are two "types" of RingBuffer
: fixed size chunks or not. I don't really want to use two separate struct
s for that, so the solution would be to never actually return a RingBuffer
.
In summary, I have two "constructor" methods on RingBuffer
that among them return 4 possible pairs of types, none of which actually is a RingBuffer
.
I'm not sure whether I should like this or if I should be disgusted, what is your opinion?
BTW, neither the number of chunks nor the chunk size is supposed to be const
. This would be a nice usage for the recently stabilized const generics, but for this project I'm interested in the non-const
case.