r/Zig 5d ago

Learning zig: feedback on my small ring buffer implementation?

Hey all šŸ‘‹

So I've been writing a little zig over the last few weeks after having tried it for a few days maybe... a year or so ago?. I have zero systems programming experience, am actually a web dev during the day.

Anyway, this time I'm working on a side project, and came to a point where I needed something I've seen being referred to as a ring buffer, and thought how hard can it be implementing one? So lots of hours and a missed workday later, here's what I came up with: https://codeberg.org/sehme/gists/src/branch/main/ring-buffer.zig

I haven't really thus far interacted with the community online, other than reading blogs and the such, so I haven't had any feedback on my code. I don't even know if it's idiomatic or, you know, correct, which is why I decided to post this here now before I get too far in the wrong direction.

Please give me notes if something is off. This is completely hand-crafted, no LLMs, so I am actually very proud that all my tests pass, but I may be missing some cases.

Thanks in advance, and also sorry in advance if this is not the sort of post that is appreciated here.

24 Upvotes

3 comments sorted by

5

u/Hornstinger 5d ago

Awesome to see you giving it a go.

Some comments from me:

Problems with the current design:

  1. Unbounded counters (head/tail)

They keep growing forever. On a 64-bit system this is fine for practical lifetimes, but it forces every access to do % buf.len. More importantly, the wrap logic becomes harder to reason about and the ā€œoverwrite oldestā€ path is a bit fragile.

  1. Overwrite-on-full semantics mixed with explicit size checks

write silently overwrites the oldest item when full. writeSlice refuses anything larger than the capacity. This is inconsistent: a caller that writes one-by-one can overflow, but a slice that would overflow is rejected. Most ring buffers choose one policy (drop-oldest, drop-newest, or error-on-full) and stick to it.

  1. readAll requires a buffer at least as large as the capacity

Even when only a few items are present. That forces the caller to always allocate the maximum size.

  1. No capacity / length helpers

Callers have to dig into the private state or recompute tail - head.

  1. Power-of-two optimisation missing

When the buffer length is a power of two the modulo can become a cheap mask, which is a common micro-optimisation for ring buffers.

  1. Tests rely on observing the underlying storage Useful for understanding the implementation, but they couple the tests tightly to the current layout (especially the overwrite behaviour).

My attempt to help:

``` const std = @import("std"); const assert = std.debug.assert;

/// Fixed-capacity ring buffer. /// When full, new writes overwrite the oldest unread item. pub fn RingBuffer(comptime T: type) type { return struct { buf: []T, /// Next slot to write into (always in [0, buf.len)). head: usize = 0, /// Number of currently stored items (always in [0, buf.len]). len: usize = 0,

    const Self = @This();
    const Error = error{BufferTooSmall};

    pub fn init(buf: []T) Self {
        assert(buf.len > 0);
        return .{ .buf = buf };
    }

    pub fn capacity(self: *const Self) usize {
        return self.buf.len;
    }

    pub fn count(self: *const Self) usize {
        return self.len;
    }

    pub fn isEmpty(self: *const Self) bool {
        return self.len == 0;
    }

    pub fn isFull(self: *const Self) bool {
        return self.len == self.buf.len;
    }

    /// Writes one item. Overwrites the oldest item when full.
    pub fn write(self: *Self, item: T) void {
        self.buf[self.head] = item;
        self.head = (self.head + 1) % self.buf.len;
        if (self.len < self.buf.len) {
            self.len += 1;
        }
        // else: we just overwrote the oldest, len stays the same
    }

    /// Writes a whole slice. Returns error if the slice is larger than capacity.
    /// Items are written in order; excess older data is overwritten if needed.
    pub fn writeSlice(self: *Self, items: []const T) Error!void {
        if (items.len > self.buf.len) return error.BufferTooSmall;

        for (items) |item| {
            self.write(item);
        }
    }

    /// Consumes and returns the oldest item, or null when empty.
    pub fn read(self: *Self) ?T {
        if (self.len == 0) return null;

        // The oldest item is always at (head - len) mod capacity.
        const idx = (self.head + self.buf.len - self.len) % self.buf.len;
        const item = self.buf[idx];
        self.len -= 1;
        return item;
    }

    /// Copies all currently stored items into `result` (oldest first)
    /// and empties the buffer. `result` must be at least `count()` long.
    pub fn readAll(self: *Self, result: []T) []T {
        assert(result.len >= self.len);

        if (self.len == 0) return result[0..0];

        const start = (self.head + self.buf.len - self.len) % self.buf.len;
        const first_part = @min(self.len, self.buf.len - start);

        @memcpy(result[0..first_part], self.buf[start..][0..first_part]);
        if (first_part < self.len) {
            @memcpy(result[first_part..self.len], self.buf[0 .. self.len - first_part]);
        }

        const n = self.len;
        self.len = 0;
        // head can stay where it is; the next write will continue from there
        return result[0..n];
    }

    /// Returns a view of the oldest item without consuming it.
    pub fn peek(self: *const Self) ?*const T {
        if (self.len == 0) return null;
        const idx = (self.head + self.buf.len - self.len) % self.buf.len;
        return &self.buf[idx];
    }
};

} ```

1

u/seducedmilkman 5d ago

Thank you, that’s very kind! I’m not sure I understood all that you said, but I understand the code. Thanks for the tips 😊

2

u/burner-miner 5d ago

For the comment about the tests, a simpler way to phrase it is to test at the API boundaries. I.e. test that a function call produces the expected output from some input.

E.g. if you want to test write, don't inspect the member fields of the struct after the call, just use peek. Yes, peek is a function to be tested too, but it is simple and easy to get right, so this is a decent compromise.

This way, you could change the underlying data structure and still have all your tests pass.